feat(interviews): add durable debrief loop

This commit is contained in:
cesnimda
2026-08-31 17:20:58 +02:00
parent c2181afaea
commit 7d5a78ad5d
8 changed files with 60 additions and 3 deletions
+3 -1
View File
@@ -52,13 +52,15 @@ public sealed class InterviewPrepTests
await prep.AddAsync("user-1", job.Id, new InterviewPrepInput(InterviewPrepCategories.Question, "What does success look like?", null, null, null), default);
await prep.AddAsync("user-1", job.Id, new InterviewPrepInput(InterviewPrepCategories.CompanyResearch, "Funding history", "Series B in 2025.", null, null), default);
await prep.AddAsync("user-1", job.Id, new InterviewPrepInput(InterviewPrepCategories.Technical, "System design", null, null, null), default);
await prep.AddAsync("user-1", job.Id, new InterviewPrepInput(InterviewPrepCategories.Debrief, "Interview debrief", "Questions asked", null, null), default);
var result = await prep.GetAsync("user-1", job.Id, default);
Assert.Equal(
new[] { InterviewPrepCategories.CompanyResearch, InterviewPrepCategories.Technical, InterviewPrepCategories.Question },
new[] { InterviewPrepCategories.CompanyResearch, InterviewPrepCategories.Technical, InterviewPrepCategories.Question, InterviewPrepCategories.Debrief },
result!.Groups.Select(g => g.Category));
Assert.Equal("Company research", result.Groups[0].Label);
Assert.Equal("Interview debrief", result.Groups[^1].Label);
}
[Fact]
+3 -2
View File
@@ -16,7 +16,7 @@ public sealed class InterviewPrepItem
public int JobApplicationId { get; set; }
public JobApplication? JobApplication { get; set; }
// company-research | technical | behavioural | star | question | note
// company-research | technical | behavioural | star | question | note | debrief
public string Category { get; set; } = InterviewPrepCategories.Note;
// The prompt side: a question to answer, or the heading of a research note.
@@ -45,12 +45,13 @@ public static class InterviewPrepCategories
public const string Star = "star";
public const string Question = "question";
public const string Note = "note";
public const string Debrief = "debrief";
// Display order in the workspace: understand the company, then the role, then yourself, then what
// you want to ask them.
public static readonly string[] Order =
{
CompanyResearch, Technical, Behavioural, Star, Question, Note,
CompanyResearch, Technical, Behavioural, Star, Question, Note, Debrief,
};
public static int Rank(string? category)
@@ -59,6 +59,7 @@ public sealed class InterviewPrepService : IInterviewPrepService
[InterviewPrepCategories.Star] = "STAR examples",
[InterviewPrepCategories.Question] = "Questions to ask them",
[InterviewPrepCategories.Note] = "Notes",
[InterviewPrepCategories.Debrief] = "Interview debrief",
};
private readonly JobTrackerContext _db;
+1
View File
@@ -110,6 +110,7 @@ Updated: 2026-08-31
- Submitted-package complete regression: backend 740/740 and frontend 60 suites with 261/261 tests passed. The full run also exposed and fixed a date-sensitive reminder fixture that mixed a fixed worker clock with `DateTime.Now` seed data.
- Career-evidence focused verification: backend/controller/schema gates passed 21/21, the evidence UI regression passed 1/1, ESLint passed with zero warnings, and the optimized Next build/TypeScript passed. Complete regression then passed backend 741/741 and frontend 61 suites with 262/262 tests.
- 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.
- Focused frontend: 2 suites, 6 tests passed.
- Full frontend: 64 suites, 272 tests passed.
- Next production build and TypeScript: passed.
@@ -337,6 +337,7 @@ export const INTERVIEW_PREP_CATEGORIES: { key: string; label: string }[] = [
{ key: "star", label: "STAR examples" },
{ key: "question", label: "Questions to ask them" },
{ key: "note", label: "Notes" },
{ key: "debrief", label: "Interview debrief" },
];
export const interviewPrepApi = {
@@ -91,6 +91,14 @@ export function ApplicationInterviewPrep({ jobId }: { jobId: number }) {
return mutate(() => interviewPrepApi.add(jobId, { category, title: value }));
};
const startDebrief = () => mutate(() => interviewPrepApi.add(jobId, {
category: "debrief",
title: t("interviewDebriefDefaultTitle"),
content: t("interviewDebriefTemplate"),
}));
const hasDebrief = board?.groups.some((group) => group.category === "debrief") ?? false;
return (
<Stack spacing={2}>
<Shell
@@ -151,6 +159,18 @@ export function ApplicationInterviewPrep({ jobId }: { jobId: number }) {
))
)}
{board?.isInterviewStage && !hasDebrief ? (
<Paper variant="outlined" sx={{ p: 2, bgcolor: "action.hover" }}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1.5} alignItems={{ sm: "center" }} justifyContent="space-between">
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{t("interviewDebriefTitle")}</Typography>
<Typography variant="body2" color="text.secondary">{t("interviewDebriefHelp")}</Typography>
</Box>
<Button variant="outlined" disabled={busy} onClick={() => void startDebrief()}>{t("interviewDebriefStart")}</Button>
</Stack>
</Paper>
) : null}
<Box component="form" onSubmit={add}>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1}>
<TextField
@@ -485,6 +505,7 @@ function interviewCategoryLabel(t: (key: any) => string, category: string) {
star: "interviewCategoryStar",
question: "interviewCategoryQuestions",
note: "interviewCategoryNotes",
debrief: "interviewCategoryDebrief",
};
return keys[category] ? t(keys[category]) : category;
}
+12
View File
@@ -360,6 +360,12 @@ export const translations = {
interviewCategoryStar: "STAR examples",
interviewCategoryQuestions: "Questions to ask them",
interviewCategoryNotes: "Notes",
interviewCategoryDebrief: "Interview debrief",
interviewDebriefTitle: "Capture the interview while it is fresh",
interviewDebriefHelp: "Record the questions, what went well, what you would improve, and any feedback or next steps.",
interviewDebriefStart: "Start debrief",
interviewDebriefDefaultTitle: "Interview debrief",
interviewDebriefTemplate: "Questions asked:\n\nWhat went well:\n\nWhat I would improve:\n\nFeedback and next steps:",
interviewAiTitle: "AI interview coach",
interviewAiSubtitle: "Uses the linked CV, job advert and application analysis. Suggestions never overwrite your notes.",
interviewAiUsing: "Using",
@@ -2735,6 +2741,12 @@ export const translations = {
interviewCategoryStar: "STAR-eksempler",
interviewCategoryQuestions: "Spørsmål du kan stille",
interviewCategoryNotes: "Notater",
interviewCategoryDebrief: "Intervjuoppsummering",
interviewDebriefTitle: "Noter intervjuet mens det er ferskt",
interviewDebriefHelp: "Skriv ned spørsmålene, hva som gikk bra, hva du ville gjort bedre, og eventuelle tilbakemeldinger eller neste steg.",
interviewDebriefStart: "Start oppsummering",
interviewDebriefDefaultTitle: "Intervjuoppsummering",
interviewDebriefTemplate: "Spørsmål som ble stilt:\n\nHva gikk bra:\n\nHva jeg ville gjort bedre:\n\nTilbakemeldinger og neste steg:",
interviewAiTitle: "AI-intervjutrening",
interviewAiSubtitle: "Bruker tilknyttet CV, stillingsannonse og søknadsanalyse. Forslag overskriver aldri notatene dine.",
interviewAiUsing: "Bruker",
@@ -102,6 +102,24 @@ test("adding a prep item posts the chosen category and title", async () => {
));
});
test("starts a structured interview debrief without inventing answers", async () => {
routeGet();
mockedApi.post.mockResolvedValue({ data: board.groups[0].items[0] } as any);
render(<ApplicationInterviewPrep jobId={7} />);
fireEvent.click(await screen.findByRole("button", { name: "Start debrief" }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith(
"/jobapplications/7/interview-prep",
{
category: "debrief",
title: "Interview debrief",
content: expect.stringContaining("Questions asked:"),
},
));
expect(mockedApi.post.mock.calls[0][1].content).not.toMatch(/Acme|achievement|metric/i);
});
test("marking an item ready patches it", async () => {
routeGet();
mockedApi.patch.mockResolvedValue({ data: board.groups[1].items[0] } as any);