Phase 5.3. Three read-only reads that answer "how suitable is this job", "how
does my experience match", "what am I missing", "what happened previously".
Timeline (GET /{id}/timeline) is an interpretation layer over JobEvent, which
stays the source of historical truth. Each row gains a readable summary, a
category and a milestone flag; events group by day. Milestones are returned
unfiltered, because narrowing the detail must not hide what actually happened.
Job analysis (GET /{id}/analysis) extracts role, company, location, employment
type, seniority, salary, technologies, skills, responsibilities and keywords
from the advert, reusing the existing SkillTagger so the vocabulary matches the
job importer. It also reports what the advert does NOT say, which is usually the
more useful half.
Career matching (GET /{id}/match) feeds the master CareerProfile into the same
JobCvMatchService the CV builder uses, so one application scores identically
whichever surface asks. It returns the score, matched and missing skills, and
which experience and project entries are the evidence for each match.
All three are deterministic and own no data — no new table, no new column, and
nothing writes to the CareerProfile, a CvVariant, or the JobApplication. The AI
narrative stays where it already was, in AiWorkspaceService's job-analysis and
career-match modules, generated only when the user asks and versioned by the
append-only AiInteraction history. Opening a section costs nothing and changes
nothing.
Frontend adds Timeline, Analysis and Match sections to the workspace, sharing
one loader so loading, empty and error states are consistent. The deterministic
answer renders first, with the AI panel below it.
345 backend tests, 104 frontend tests, type check, production build all pass
locally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
12 KiB
Application Workspace (Phase 5)
Phase 5, Milestones 1–2 (2026-07-19). The per-application workspace: what it is, what it deliberately is not, and how it composes existing systems. Companion to
cv-builder.md,ai-career-assistant.md,career-profile-model.md.
What it is
A dedicated surface for one JobApplication at /applications/{id}, so an application is a place you
work rather than a row you edit in a modal. Job tracking stays the product; the workspace is the
application's home.
Core principle: the user should never ask "what do I do next?" — the overview always answers it.
What it is NOT
The workspace owns no data and duplicates none. It is an aggregate read plus a navigation shell:
| Section | Backed by (existing system) |
|---|---|
| Timeline | JobEvent — interpreted, never replaced |
| Analysis / Match | the advert and the master CareerProfile, read deterministically |
| Checklist | ApplicationChecklistItem — completion state only, seeded from the readiness signals |
| CV | Phase 4 CvVariant — a lens over the master CareerProfile |
| Analysis / Match / Interview | Phase 5 AiWorkspacePanel + AiInteraction history |
| Documents | Attachment |
| Communication | Correspondence |
| Activity / Timeline | JobEvent |
| Stage semantics | JobPipeline |
No career data is copied into the application. Nothing in this feature writes to the master profile, a CV variant, or a cover letter.
Backend
GET /api/jobapplications/{id}/workspace → WorkspaceOverviewDto
(ApplicationWorkspaceController + ApplicationWorkspaceService).
One aggregate read instead of the page fanning out: role/company/location/salary, status + pipeline
group, applied/deadline/follow-up dates, the attached CV variant (id, name, theme), cover-letter
presence, document count, AI interaction count + last run, recent JobEvent activity, and the
computed next recommended action.
Read-only and tenant-scoped (OwnerUserId), returning 404 for another user's application.
Next recommended action
Milestone 2 moved this onto the checklist: the first pending checklist item, ordered by category
priority (preparation → submission → follow-up → interview → custom) then the user's own
ordering. null means nothing is outstanding.
That means the overview cannot recommend something the user has already ticked off, a dismissed item never comes back as a recommendation, and a task the user added themselves can legitimately be the next action. There is no second ruleset to keep in sync.
The checklist
ApplicationChecklistItem — a workflow guidance layer, not a store of truth. It records only "is
this step done, and does the user still want it". The CV still lives in CvVariant, documents in
Attachment, history in JobEvent, follow-up in JobApplication.FollowUpAt.
System items and auto-completion
Each default item carries a stable SystemKey and usually an AutoSignal — the same signal
/readiness already computed. On every read the service re-syncs:
- signal satisfied + item pending → done,
IsAutoCompleted = true - signal no longer satisfied + item was auto-completed → back to pending
- a manual tick clears
IsAutoCompletedand therefore sticks, even against the signal
So "readiness says the CV is missing" and "the checklist says Prepare a CV is pending" cannot drift apart — they read the same state. The user always wins over the signal.
| System key | Category | Signal |
|---|---|---|
review-job-details |
preparation | advert text present |
complete-career-profile |
preparation | career profile with at least one experience |
prepare-cv |
preparation | CV variant attached, or tailored CV text |
review-cv-match |
preparation | manual |
create-cover-letter |
preparation | cover letter present |
attach-portfolio |
preparation | HasPortfolio |
attach-supporting-documents |
preparation | at least one Attachment |
save-application-answers |
preparation | saved answer draft in Notes |
capture-recruiter-contact |
preparation | Company.RecruiterEmail |
confirm-submitted |
submission | applied date set and out of the prospect stage |
add-follow-up-reminder |
follow-up | FollowUpAt set |
set-next-action |
follow-up | NextAction written |
prepare-interview-notes |
interview | prep notes present, or not at an interview stage |
research-company |
interview | manual |
Seeding is idempotent per (JobApplicationId, SystemKey) — enforced by a unique index, so a re-read
never duplicates. Custom items have a NULL SystemKey; both SQLite and MariaDB treat NULLs as
distinct in a unique index, so a user can add as many as they like.
Deleting a system item dismisses it (a hard delete would be undone by the next seed); deleting a custom item removes the row. Dismissed items leave the progress denominator entirely.
API
/api/jobapplications/{id}/checklist — GET (seeds + syncs + returns items and progress),
POST (custom item), PATCH /{itemId}, DELETE /{itemId}, PUT /order (array of ids).
Tenant-scoped on OwnerUserId; another user's application is a 404.
Relationship to the other systems
- Not
JobEvent— the checklist is forward-looking intent;JobEventis the append-only history. - Not follow-ups —
FollowUpAt,RulesEngineand the reminder hosted service still own scheduling. The checklist only asks whether a follow-up exists. - Not
Attachment/CvVariant— it reads their presence as a signal and stores nothing of them.
Future AI suggestions
An AI-suggested task is just a checklist row with IsSystemGenerated = false and no AutoSignal,
created after the user approves it. Nothing in the AI path may create, complete or delete an item
without approval — same rule as everywhere else (ai-career-assistant.md).
Schema provisioning
ApplicationChecklistItems follows the established MariaDB-safe path: the scaffolded migration
(20260719085904_AddApplicationChecklistItems) is a no-op, and the table is created by the
idempotent reconciler in StartupInitializationExtensions, which has correct DDL per provider. A
SQLite-scaffolded migration would emit TEXT datetimes and a PK without AUTO_INCREMENT on MariaDB —
the failure that crashed prod startup for the Phase 4 tables.
Verified on MariaDB 11: int AUTO_INCREMENT PK, varchar/datetime(6)/tinyint(1) columns, both
indexes inside the 3072-byte key limit, cascade delete from JobApplications, the unique index
rejecting a duplicate system key, and NULL system keys not colliding.
Frontend
ApplicationWorkspacePage (/applications/:id) — a left nav plus a content pane, section selected by
?section=, so a section is linkable and survives refresh. Reached from the job dialog's "Open
application workspace" button.
The dialog passes an optional onOpenWorkspace callback rather than calling useNavigate itself:
JobDetailsDialog must stay renderable without a <Router> (several suites mount it standalone), so
router context belongs to the caller.
The Checklist section (ApplicationChecklist) groups items by category, shows a completion bar, and
supports tick/untick, add, remove and reorder. System items are labelled "Detected" when a signal
completed them, custom items "Yours". Every mutation re-reads, because only the backend's sync knows
the real post-mutation state.
Implemented now: Overview, Checklist, Job Details, and the sections that reuse an existing component
(Analysis/Match/Interview → AiWorkspacePanel, Documents → Attachments,
Communication → Correspondence). Sections owned by later milestones state their milestone instead of
faking functionality.
Relationship to /readiness
GET /{id}/readiness keeps its DTO shape (score, level, completed, missing, reminders,
workflowSignal) and still backs the dialog's Readiness tab — but as of Milestone 2 it no longer runs
its own parallel checklist. It projects the persisted checklist:
completed/missing— the live (non-dismissed) items by statusscore— the checklist completion percentagelevel— Ready ≥ 80, Needs polish ≥ 60, otherwise Needs workreminders/workflowSignal— unchanged;BuildWorkflowSignalremains the health/attention view
So the division is: the checklist is the workflow the user drives, readiness is the calculation and health indicator derived from it. One system, two projections.
Application intelligence (Phase 5.3)
Three read-only reads that answer "how suitable is this job", "how does my experience match", "what am I missing", "what happened previously". All deterministic, all owned by nothing:
| Endpoint | Reads | Owns |
|---|---|---|
GET /{id}/timeline |
JobEvent |
nothing |
GET /{id}/analysis |
JobApplication.Description |
nothing |
GET /{id}/match |
CareerProfile + the advert |
nothing |
No new table, no new column. ApplicationTimelineService and ApplicationIntelligenceService write
nothing at all.
The AI boundary
The deterministic answer and the AI narrative are separate on purpose.
- The three endpoints above never call the AI. Opening the Analysis or Match section costs nothing and cannot change anything — the page renders a computed answer.
- The narrative lives where it already did:
AiWorkspaceService'sjob-analysisandcareer-matchmodules, reached fromAiWorkspacePanel, generated only when the user asks. - Every generation is appended as an
AiInteraction— that append-only history is the versioning, and the user restores, compares or deletes from it. - AI output is a suggestion. Nothing in this phase writes to the
CareerProfile, aCvVariant, a cover letter, or theJobApplication.Match_never_writes_to_the_career_profilepins that.
So a user gets a trustworthy number for free, and pays for prose only when they want it.
Timeline
JobEvent stays the source of historical truth; the service is an interpretation layer over it. Each
row gains a readable summary (("StatusChanged", "Applied", "Interview") → "Moved from Applied to
Interview"), a category (lifecycle, stage, follow-up, communication, ai) and a milestone
flag. Events group by day with relative labels.
Milestones are the stages that mean something happened — applied, interview, offer, rejected, accepted, declined — plus creation and replies received. They are returned unfiltered: narrowing the detail below must not hide what actually happened.
An unrecognised future Type degrades to a humanised sentence rather than disappearing.
Job analysis
Structured extraction from the advert, reusing the existing SkillTagger so the vocabulary matches
the job importer and the CV match. Facts (role, company, location, employment type, seniority,
salary), lists (technologies, skills, responsibilities, requirements, keywords, interview topics),
and — deliberately — what the advert does not say, which is usually the more useful half.
With no advert saved it degrades to the fields on the application and says so.
Career matching
Feeds the master CareerProfile into the same JobCvMatchService the CV builder uses, so one
application scores identically whichever surface asks. Returns the score and band, matched and
missing skills, and — the part that makes it actionable — which experience and project entries are
the evidence for each matched keyword, ranked by hit count.
Suggestions describe what the user could change. They never change it.
With no profile it returns score 0 and asks the user to build one, rather than implying a bad match.
Extension points
- New section: add to
WORKSPACE_SECTIONSand render it; nav is data-driven. - New next-action rule: add one clause to
ApplicationWorkspaceService.NextStep— ordered, so position is the priority. - More overview data: extend
WorkspaceOverviewDto; the page reads one payload.
Milestones
- ✅ Workspace foundation — route, nav shell, aggregate overview, next recommended action.
- ✅ Checklist and progress tracking — persisted items, auto-completion from the readiness signals, custom items, reordering, dismissal; readiness refactored into a projection of it.
- ✅ Application intelligence — timeline interpretation, structured job analysis, career matching (Phase 5.3, all three deterministic and read-only).
- CV integration.
- Cover letter workflow. 8. Documents. 9. Interview preparation. 10. Dashboard improvements.