feat(interviews): add durable debrief loop
This commit is contained in:
@@ -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.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.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.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);
|
var result = await prep.GetAsync("user-1", job.Id, default);
|
||||||
|
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
new[] { InterviewPrepCategories.CompanyResearch, InterviewPrepCategories.Technical, InterviewPrepCategories.Question },
|
new[] { InterviewPrepCategories.CompanyResearch, InterviewPrepCategories.Technical, InterviewPrepCategories.Question, InterviewPrepCategories.Debrief },
|
||||||
result!.Groups.Select(g => g.Category));
|
result!.Groups.Select(g => g.Category));
|
||||||
Assert.Equal("Company research", result.Groups[0].Label);
|
Assert.Equal("Company research", result.Groups[0].Label);
|
||||||
|
Assert.Equal("Interview debrief", result.Groups[^1].Label);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ public sealed class InterviewPrepItem
|
|||||||
public int JobApplicationId { get; set; }
|
public int JobApplicationId { get; set; }
|
||||||
public JobApplication? JobApplication { 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;
|
public string Category { get; set; } = InterviewPrepCategories.Note;
|
||||||
|
|
||||||
// The prompt side: a question to answer, or the heading of a research 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 Star = "star";
|
||||||
public const string Question = "question";
|
public const string Question = "question";
|
||||||
public const string Note = "note";
|
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
|
// Display order in the workspace: understand the company, then the role, then yourself, then what
|
||||||
// you want to ask them.
|
// you want to ask them.
|
||||||
public static readonly string[] Order =
|
public static readonly string[] Order =
|
||||||
{
|
{
|
||||||
CompanyResearch, Technical, Behavioural, Star, Question, Note,
|
CompanyResearch, Technical, Behavioural, Star, Question, Note, Debrief,
|
||||||
};
|
};
|
||||||
|
|
||||||
public static int Rank(string? category)
|
public static int Rank(string? category)
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ public sealed class InterviewPrepService : IInterviewPrepService
|
|||||||
[InterviewPrepCategories.Star] = "STAR examples",
|
[InterviewPrepCategories.Star] = "STAR examples",
|
||||||
[InterviewPrepCategories.Question] = "Questions to ask them",
|
[InterviewPrepCategories.Question] = "Questions to ask them",
|
||||||
[InterviewPrepCategories.Note] = "Notes",
|
[InterviewPrepCategories.Note] = "Notes",
|
||||||
|
[InterviewPrepCategories.Debrief] = "Interview debrief",
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly JobTrackerContext _db;
|
private readonly JobTrackerContext _db;
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- 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.
|
- 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.
|
- Focused frontend: 2 suites, 6 tests passed.
|
||||||
- Full frontend: 64 suites, 272 tests passed.
|
- Full frontend: 64 suites, 272 tests passed.
|
||||||
- Next production build and TypeScript: 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: "star", label: "STAR examples" },
|
||||||
{ key: "question", label: "Questions to ask them" },
|
{ key: "question", label: "Questions to ask them" },
|
||||||
{ key: "note", label: "Notes" },
|
{ key: "note", label: "Notes" },
|
||||||
|
{ key: "debrief", label: "Interview debrief" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const interviewPrepApi = {
|
export const interviewPrepApi = {
|
||||||
|
|||||||
@@ -91,6 +91,14 @@ export function ApplicationInterviewPrep({ jobId }: { jobId: number }) {
|
|||||||
return mutate(() => interviewPrepApi.add(jobId, { category, title: value }));
|
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 (
|
return (
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
<Shell
|
<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}>
|
<Box component="form" onSubmit={add}>
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={1}>
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={1}>
|
||||||
<TextField
|
<TextField
|
||||||
@@ -485,6 +505,7 @@ function interviewCategoryLabel(t: (key: any) => string, category: string) {
|
|||||||
star: "interviewCategoryStar",
|
star: "interviewCategoryStar",
|
||||||
question: "interviewCategoryQuestions",
|
question: "interviewCategoryQuestions",
|
||||||
note: "interviewCategoryNotes",
|
note: "interviewCategoryNotes",
|
||||||
|
debrief: "interviewCategoryDebrief",
|
||||||
};
|
};
|
||||||
return keys[category] ? t(keys[category]) : category;
|
return keys[category] ? t(keys[category]) : category;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -360,6 +360,12 @@ export const translations = {
|
|||||||
interviewCategoryStar: "STAR examples",
|
interviewCategoryStar: "STAR examples",
|
||||||
interviewCategoryQuestions: "Questions to ask them",
|
interviewCategoryQuestions: "Questions to ask them",
|
||||||
interviewCategoryNotes: "Notes",
|
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",
|
interviewAiTitle: "AI interview coach",
|
||||||
interviewAiSubtitle: "Uses the linked CV, job advert and application analysis. Suggestions never overwrite your notes.",
|
interviewAiSubtitle: "Uses the linked CV, job advert and application analysis. Suggestions never overwrite your notes.",
|
||||||
interviewAiUsing: "Using",
|
interviewAiUsing: "Using",
|
||||||
@@ -2735,6 +2741,12 @@ export const translations = {
|
|||||||
interviewCategoryStar: "STAR-eksempler",
|
interviewCategoryStar: "STAR-eksempler",
|
||||||
interviewCategoryQuestions: "Spørsmål du kan stille",
|
interviewCategoryQuestions: "Spørsmål du kan stille",
|
||||||
interviewCategoryNotes: "Notater",
|
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",
|
interviewAiTitle: "AI-intervjutrening",
|
||||||
interviewAiSubtitle: "Bruker tilknyttet CV, stillingsannonse og søknadsanalyse. Forslag overskriver aldri notatene dine.",
|
interviewAiSubtitle: "Bruker tilknyttet CV, stillingsannonse og søknadsanalyse. Forslag overskriver aldri notatene dine.",
|
||||||
interviewAiUsing: "Bruker",
|
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 () => {
|
test("marking an item ready patches it", async () => {
|
||||||
routeGet();
|
routeGet();
|
||||||
mockedApi.patch.mockResolvedValue({ data: board.groups[1].items[0] } as any);
|
mockedApi.patch.mockResolvedValue({ data: board.groups[1].items[0] } as any);
|
||||||
|
|||||||
Reference in New Issue
Block a user