Files
jobtrackingapp/job-tracker-ui/src/applicationWorkspace.ts
T
cesnimda 3d74baef78
CI and Deploy / test (push) Failing after 3m54s
CI and Deploy / deploy (push) Has been skipped
feat(workspace): interview and follow-up workflow
Phase 5.5. Completes the lifecycle after submission: prepare, communicate, chase.

Interview preparation gets a durable, user-owned store. There were already two
per-application AI stores, InterviewPrepNote and AiWorkspaceNote, but both are
caches that regenerate when their context signature changes — anything a user
typed into them would eventually be overwritten. InterviewPrepItem is the side
nothing regenerates, covering company research, technical notes, behavioural
answers, STAR examples and the user's own questions in one table, because those
categories differ only by label and adding one must not need a migration. Each
item records whether the user wrote it or accepted a suggestion, and an
IsPrepared flag makes the section double as the preparation checklist.

Generation stays in the existing AiWorkspaceService "interview" module, appended
to AiInteraction as before. A suggestion is history until the user adds it as a
prep item; opening the section generates nothing.

Follow-up reuses what exists rather than adding a tracker. The date is
JobApplication.FollowUpAt, the same field RulesEngine and the reminder hosted
service already act on, so reminders keep working with no new wiring. The task
stays an ApplicationChecklistItem in the follow-up category — the section counts
open tasks without owning them. The record is a FollowUpSet JobEvent, the same
type the rest of the app emits.

Communication is untouched: Correspondence already owns recruiter contacts,
history and notes, and the workspace already mounted it.

The timeline interpreter learned five more types — InterviewScheduled,
InterviewCompleted and OfferReceived as milestones, FollowUpCreated and
FollowUpCompleted as routine, deliberately outside the milestone spine so it
stays a summary of what actually happened. JobEvent remains the history source.

InterviewPrepItems is reconciler-owned with a no-op migration, guarded on
JobApplications, and verified on a fresh MariaDB 11: int AUTO_INCREMENT primary
key, varchar owner and title, tinyint flag, datetime(6), composite index inside
the key limit.

371 backend tests, 128 frontend tests, Release build and the production build all
pass locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:01:33 +02:00

304 lines
11 KiB
TypeScript

import { api } from "./api";
// Mirrors WorkspaceOverviewDto. Read-only aggregate — the workspace owns no data.
export type WorkspaceCv = {
variantId: number | null;
variantName: string | null;
themeId: string | null;
hasTailoredCvText: boolean;
updatedAtUtc: string | null;
};
export type WorkspaceActivity = { type: string; detail: string | null; at: string };
export type WorkspaceNextStep = { key: string; label: string; reason: string; section: string | null };
export type WorkspaceOverview = {
id: number;
jobTitle: string;
company: string | null;
location: string | null;
salary: string | null;
status: string;
stageGroup: string;
stageOrder: number;
dateApplied: string | null;
deadline: string | null;
followUpAt: string | null;
nextAction: string | null;
jobUrl: string | null;
hasJobDescription: boolean;
cv: WorkspaceCv;
hasCoverLetter: boolean;
documentCount: number;
hasPortfolio: boolean;
aiInteractionCount: number;
lastAiAtUtc: string | null;
recentActivity: WorkspaceActivity[];
nextStep: WorkspaceNextStep | null;
checklistProgress: ChecklistProgress | null;
};
// Milestone 2 — the application checklist. One workflow surface: system items seed from the same
// readiness signals the backend already computed, and the user owns everything after that.
export type ChecklistStatus = "pending" | "done" | "dismissed";
export type ChecklistItem = {
id: number;
systemKey: string | null;
title: string;
description: string | null;
category: string;
status: ChecklistStatus;
section: string | null;
sortOrder: number;
isSystemGenerated: boolean;
isAutoCompleted: boolean;
completedAt: string | null;
};
export type ChecklistProgress = { total: number; completed: number; dismissed: number; percent: number };
export type Checklist = { items: ChecklistItem[]; progress: ChecklistProgress };
export const CHECKLIST_CATEGORIES: { key: string; label: string }[] = [
{ key: "preparation", label: "Before applying" },
{ key: "submission", label: "Submitting" },
{ key: "follow-up", label: "Follow-up" },
{ key: "interview", label: "Interview" },
{ key: "custom", label: "Your own tasks" },
];
// Workspace navigation. Sections map to the Phase 5 milestones; each is added as its milestone lands
// so the workspace is always usable rather than a shell of placeholders.
export type WorkspaceSectionKey =
| "overview" | "job-details" | "analysis" | "match" | "checklist" | "cv" | "cover-letter"
| "portfolio" | "documents" | "interview" | "timeline" | "notes" | "communication";
export const WORKSPACE_SECTIONS: { key: WorkspaceSectionKey; label: string; milestone?: number }[] = [
{ key: "overview", label: "Overview" },
{ key: "job-details", label: "Job Details" },
{ key: "analysis", label: "Analysis" },
{ key: "match", label: "Match" },
{ key: "checklist", label: "Checklist" },
{ key: "cv", label: "CV" },
{ key: "cover-letter", label: "Cover Letter" },
{ key: "portfolio", label: "Portfolio", milestone: 8 },
{ key: "documents", label: "Documents" },
{ key: "interview", label: "Interview Prep" },
{ key: "timeline", label: "Timeline" },
{ key: "notes", label: "Notes", milestone: 3 },
{ key: "communication", label: "Communication" },
];
export const applicationWorkspaceApi = {
overview: (jobId: number) =>
api.get<WorkspaceOverview>(`/jobapplications/${jobId}/workspace`).then((r) => r.data),
};
// Phase 5.3 — Application Intelligence. All three reads are deterministic and read-only; the AI
// narrative stays on the existing /ai routes, which require the user to ask for it.
export type TimelineEvent = {
id: number;
type: string;
category: string;
summary: string;
detail: string | null;
isMilestone: boolean;
at: string;
};
export type TimelineDay = { date: string; label: string; events: TimelineEvent[] };
export type Timeline = {
days: TimelineDay[];
milestones: TimelineEvent[];
categories: string[];
totalEvents: number;
};
export type JobAnalysis = {
role: string | null;
company: string | null;
location: string | null;
employmentType: string | null;
seniority: string | null;
salary: string | null;
technologies: string[];
skills: string[];
responsibilities: string[];
keywords: string[];
summary: string;
importantRequirements: string[];
interviewTopics: string[];
missingInformation: string[];
hasJobDescription: boolean;
aiSuggestionCount: number;
};
export type MatchEvidence = { title: string; subtitle: string | null; matched: string[] };
export type CareerMatch = {
score: number;
band: string;
hasEnoughSignal: boolean;
hasCareerProfile: boolean;
matchedSkills: string[];
missingSkills: string[];
relevantExperience: MatchEvidence[];
relevantProjects: MatchEvidence[];
suggestions: string[];
aiSuggestionCount: number;
};
export const TIMELINE_CATEGORY_LABELS: Record<string, string> = {
lifecycle: "Lifecycle",
stage: "Stage",
"follow-up": "Follow-up",
communication: "Communication",
ai: "AI",
};
export const applicationIntelligenceApi = {
timeline: (jobId: number, category?: string, milestonesOnly?: boolean) =>
api
.get<Timeline>(`/jobapplications/${jobId}/timeline`, {
params: { category: category || undefined, milestonesOnly: milestonesOnly || undefined },
})
.then((r) => r.data),
analysis: (jobId: number) =>
api.get<JobAnalysis>(`/jobapplications/${jobId}/analysis`).then((r) => r.data),
match: (jobId: number) => api.get<CareerMatch>(`/jobapplications/${jobId}/match`).then((r) => r.data),
};
// Phase 5.4 — Application Assets. CV variant CRUD, preview, PDF export and version history stay on
// /api/cv (the existing CV builder). These types cover only what is application-scoped.
export type CvVariantSummary = {
id: number;
name: string;
themeId: string;
publicSlug: string;
isPublic: boolean;
version: number;
jobApplicationId: number | null;
updatedAtUtc: string;
};
export type ApplicationCv = {
attachedVariantId: number | null;
attachedVariantName: string | null;
attachedThemeId: string | null;
attachedVersion: number | null;
attachedUpdatedAtUtc: string | null;
attachedIsPublic: boolean;
hasTailoredCvText: boolean;
availableVariants: CvVariantSummary[];
};
export type TailoringSuggestion = { kind: string; title: string; detail: string | null; items: string[] };
export type TailoringPlan = {
hasJobDescription: boolean;
hasCareerProfile: boolean;
hasAttachedVariant: boolean;
matchScore: number;
suggestions: TailoringSuggestion[];
aiSuggestionCount: number;
};
export type CoverLetterVersion = {
version: number;
source: string;
aiAction: string | null;
length: number;
createdAtUtc: string;
isCurrent: boolean;
};
export type CoverLetter = {
text: string | null;
currentVersion: number;
versions: CoverLetterVersion[];
aiSuggestionCount: number;
};
export const applicationAssetsApi = {
cv: (jobId: number) => api.get<ApplicationCv>(`/jobapplications/${jobId}/cv`).then((r) => r.data),
attachVariant: (jobId: number, variantId: number | null) =>
api.put<ApplicationCv>(`/jobapplications/${jobId}/cv`, { variantId }).then((r) => r.data),
tailoring: (jobId: number) =>
api.get<TailoringPlan>(`/jobapplications/${jobId}/tailoring`).then((r) => r.data),
coverLetter: (jobId: number) =>
api.get<CoverLetter>(`/jobapplications/${jobId}/cover-letter`).then((r) => r.data),
saveCoverLetter: (jobId: number, text: string, source = "manual", aiAction?: string) =>
api.put<CoverLetter>(`/jobapplications/${jobId}/cover-letter`, { text, source, aiAction }).then((r) => r.data),
restoreCoverLetter: (jobId: number, version: number) =>
api.post<CoverLetter>(`/jobapplications/${jobId}/cover-letter/versions/${version}/restore`).then((r) => r.data),
};
// Phase 5.5 — Interview preparation and follow-up. Prep content is the user's; AI suggestions come
// from the existing /ai routes and only land here once accepted.
export type InterviewPrepItem = {
id: number;
category: string;
title: string;
content: string | null;
source: string;
isPrepared: boolean;
sortOrder: number;
updatedAtUtc: string;
};
export type InterviewPrepGroup = { category: string; label: string; items: InterviewPrepItem[] };
export type InterviewPrepBoard = {
groups: InterviewPrepGroup[];
total: number;
prepared: number;
percent: number;
isInterviewStage: boolean;
aiSuggestionCount: number;
};
export type FollowUp = {
followUpAt: string | null;
nextAction: string | null;
responseReceived: boolean;
openFollowUpTasks: number;
};
export const INTERVIEW_PREP_CATEGORIES: { key: string; label: string }[] = [
{ key: "company-research", label: "Company research" },
{ key: "technical", label: "Technical preparation" },
{ key: "behavioural", label: "Behavioural questions" },
{ key: "star", label: "STAR examples" },
{ key: "question", label: "Questions to ask them" },
{ key: "note", label: "Notes" },
];
export const interviewPrepApi = {
get: (jobId: number) =>
api.get<InterviewPrepBoard>(`/jobapplications/${jobId}/interview-prep`).then((r) => r.data),
add: (jobId: number, body: { category?: string; title: string; content?: string; source?: string }) =>
api.post<InterviewPrepItem>(`/jobapplications/${jobId}/interview-prep`, body).then((r) => r.data),
update: (jobId: number, itemId: number, body: Partial<Pick<InterviewPrepItem, "title" | "content" | "category" | "isPrepared">>) =>
api.patch<InterviewPrepItem>(`/jobapplications/${jobId}/interview-prep/${itemId}`, body).then((r) => r.data),
remove: (jobId: number, itemId: number) =>
api.delete(`/jobapplications/${jobId}/interview-prep/${itemId}`).then(() => undefined),
followUp: (jobId: number) =>
api.get<FollowUp>(`/jobapplications/${jobId}/follow-up`).then((r) => r.data),
setFollowUp: (jobId: number, followUpAt: string | null, nextAction?: string | null) =>
api.put<FollowUp>(`/jobapplications/${jobId}/follow-up`, { followUpAt, nextAction }).then((r) => r.data),
};
export const applicationChecklistApi = {
get: (jobId: number) =>
api.get<Checklist>(`/jobapplications/${jobId}/checklist`).then((r) => r.data),
add: (jobId: number, title: string, description?: string, category?: string) =>
api.post<ChecklistItem>(`/jobapplications/${jobId}/checklist`, { title, description, category }).then((r) => r.data),
update: (jobId: number, itemId: number, patch: Partial<Pick<ChecklistItem, "title" | "description" | "category" | "status">>) =>
api.patch<ChecklistItem>(`/jobapplications/${jobId}/checklist/${itemId}`, patch).then((r) => r.data),
remove: (jobId: number, itemId: number) =>
api.delete(`/jobapplications/${jobId}/checklist/${itemId}`).then(() => undefined),
reorder: (jobId: number, orderedIds: number[]) =>
api.put<Checklist>(`/jobapplications/${jobId}/checklist/order`, orderedIds).then((r) => r.data),
};