Skip to content

feat(timeline): add the Job Stages Timeline — per-job stage history that drives job status, with interviewers and prep lists - #126

Merged
Gsync merged 30 commits into
devfrom
interview-prep
Sep 21, 2026
Merged

Gsync merged 30 commits into
devfrom
interview-prep

Conversation

@Gsync

@Gsync Gsync commented Sep 20, 2026

Copy link
Copy Markdown
Owner

Summary

Adds a per-job stage timeline. A job now moves through user-extensible stages (Applied → Phone Screen → 2nd Technical Interview → Offer …), and Job.statusId is derived from the current stage instead of being set independently. Each stage can carry interviewers (linked contacts) and a prep list (question-bank entries), and the job-details page gets a Timeline tab to manage all of it.

What changed

Data model

  • Four new models: JobStageType (per-user vocabulary, seeded from JOB_STAGES at signup and by migration), JobStage, JobStageInterviewer, JobStagePrepQuestion. Adds a Withdrawn status.
  • Backfill migration seeds stage types for existing users. Jobs with no stages keep their status; that is a supported permanent state.
  • Backup/restore: the four models are in MODEL_SPECS / INSERT_ORDER / the Zod schema. Stage-type status is exported by value and resolved on import, and a pre-stages backup file reseeds stage types.

Status invariant. Job.statusId (and applied / appliedDate) always follows the current stage. All six write paths maintain it: first-stage creation (all three job-creation sites), add/edit stage, delete-current-stage, updateJobStatus, updateJob, and the MCP updateJobFromNames. A status change appends a stage only when the parent status differs, so re-picking Interview while on "2nd Technical Interview" adds nothing.

Security. JobStage and both join tables have no ownership column. JobStage traverses Job: { userId }, and the joins check both sides on write (stage via the job, contact/question via createdBy). Deleting a question that is on any prep list is blocked.

UI

  • Job details: Timeline tab with a vertical Stage History rail, and a stage detail panel split into Overview / Interviewer / Prep List tabs. The Interviewer tab carries a count badge.
  • Update Status menu in the job header. The old status picker is folded into it, and stage actions are disabled with a visible reason.
  • Add/Edit Stage dialog with custom stage types. Format/duration/location apply to interview stages only.
  • Link Interviewers dialog with inline contact creation, and Add to Prep List dialog with a creatable question category. Prep answers expand inline.
  • Stage delete confirmation names the cascading interviewer and prep-question counts (DeleteAlertDialog now accepts a node).
  • Library → Stages tab: list, reorder, rename, guarded delete.
  • Header renamed "Match with AI" → "AI Match"; job-details tabs reordered.

Docs. New wiki/timeline.md help page; wiki/jobs.md and the index updated.

Notes for review

  • Stage ordering is JavaScript-side (src/lib/jobs/sortStages.ts), deliberately not Prisma orderBy. SQLite sorts NULL first and nulls: "last" is unsupported.
  • The stage-type resolvers are never called inside a transaction, since they write through the base client and would contend for SQLite's write lock.
  • Migration adds tables and seeds rows; review prisma/migrations/*_job_stages_timeline/migration.sql.

Testing

  • Unit: new specs for the stage actions, interviewers, prep list, status→stage, resolver, backup round-trip/schema/ordering, and the Timeline components.
  • E2E: e2e/timeline.spec.ts covers stage→status and interviewer→contacts.

🤖 Generated with Claude Code

Gsync and others added 30 commits September 19, 2026 08:53
…-type resolver and signup seeding

Phase 1 of the job stages timeline. Replaces the vestigial Interview model
with four models behind Job.statusId, which stays the column every existing
consumer reads.

- schema: JobStageType, JobStage, JobStageInterviewer and JobStagePrepQuestion
  in; Interview out, along with Contact.interviewId. JobStagePrepQuestion's
  questionId deliberately does not cascade.
- migration: tables, the partial UNIQUE(jobId) WHERE isCurrent index, the
  Withdrawn JobStatus row, fourteen stage types per existing user, and a
  per-job backfill giving every job exactly one current stage. Verified on
  dev: 191 jobs, 191 current stages, 0 invariant breaks.
- constants: Withdrawn appended to JOB_STATUSES, plus JOB_STAGES,
  TERMINAL_STAGE_STATUSES and STAGE_OUTCOMES. A stage type's value is
  canonicalizeEntityValue(label), not status.value, so "Offer Accepted"
  cannot mint a duplicate beside "offer-accepted".
- resolve: resolveJobStageType and resolveStageTypeForStatusId, following the
  resolveEntity pattern rather than reusing it, since that delegate cannot
  carry statusId and sortOrder.
- signup: seeds the fourteen types after the JobStatus upserts.
- mock.actions: drops the now-unresolvable prisma.interview.deleteMany; job
  deletion cascades to JobStage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…and the Zod schema, export stage-type status by value, drop Interview

MODEL_SPECS and BackupDataSchema land together because manifest.ts's countRows
walks INSERT_ORDER while indexing BackupData, so neither compiles green alone.
Question moves ahead of the stage models: JobStagePrepQuestion.questionId is
Restrict, so the reversed DELETE_ORDER must clear prep rows before questions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…age types from a pre-stages file and seed them in the round-trip helper

The pre-contacts reseed test now strips the stage groups too: v1.1.19 predates
both features, and JobStageInterviewer.contactId is a required FK, so leaving
interviewers behind aborts the import on a contact the file no longer carries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rdering/invariant helpers

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ns behind a barrel

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd a current stage, and stop re-stamping appliedDate

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…h and load stages on the details page

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…actions

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… stop re-stamping appliedDate there, and assert stages on the job-details include

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… interviewer contact link

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… question deletion

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…istory list

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…st, inline asked toggles and in-place notes

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tageless empty state

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cker into it and gate the stage actions with a visible reason

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… and interview-only fields

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…creation, and widen getAllContacts with title and company

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ion-category combobox case

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ename and a guarded delete

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…to-contacts, add the Job Timeline help page, and fix the greenhouse runner's prisma mock

Two Playwright flows in e2e/timeline.spec.ts cover the paths that cross
surfaces: adding an Offer stage moving the job's status on the Jobs list,
and linking an interviewer putting them on the job's Contacts tab. The
cleanup registry and route gain jobStageType, with its own referenceCount
branch so a new RefModel cannot fall through to the Location counts.

wiki/timeline.md documents the tab, the derived status, interviewers,
prep lists and Library stage types; wiki/jobs.md's status and details-page
sections were stale (Withdrawn, the Update Status button, six tabs).
WIKI_FEATURES gains "timeline" — feature is a closed enum.

greenhouse-runner.spec.ts mocked @prisma/client by hand and had no
jobStatus.findUnique, jobStageType or jobStage delegates, so every save
through createFirstStage threw into the runner's catch and seven tests
failed on counts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…etion, and compact the job header controls

Stage order now follows JobStageType.sortOrder before date, so a job added
weeks after it was applied to still reads New then Applied instead of being
reordered by the date it entered JobSync. Dates only break ties within one
sortOrder, and undated stages sort last among their peers rather than last
overall. A custom stage type typed into the Add Stage dialog slots in after
the last type of its own parent status and shifts the rest down, so it lands
beside its siblings instead of after the terminal stages.

StageDetailPanel gains a delete control beside Edit, behind the shared
DeleteAlertDialog — deleteJobStage existed in the action layer with no UI
caller. Deleting the current stage warns that the job's status follows the
promoted stage, and that promotion now selects by sortOrder too.

The job header's Edit and Delete become icon-only buttons and Update Status
becomes an outline button with a labelled menu group, so the row fits beside
the docked chat panel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ry rail, derive the header status from the current stage, and rename Match with AI to AI Match

The Timeline tab showed the same stages twice: a horizontal stepper card
across the top and the Stage History list below it. The stepper is gone and
its visual language moved into the list — a green tick for each stage behind
the current one, a filled marker for the current one, a hollow marker for the
greyed Offer step, joined top to bottom by a connector that runs emerald up to
the current stage and neutral after it. StageStepper.tsx and its spec are
deleted; the assertions that still apply moved to StageHistoryList.spec.tsx.

The job header's status badge no longer mirrors job.Status in component state.
It reads the current stage's parent status, which the six server-side write
paths already keep Job.statusId equal to, so a stage add, edit or delete that
moves the status can no longer leave the badge stale. onChangeStatus now
awaits the stage reload, since the badge reads that stage.

The header button and the jobs-list help text called the same action "Match
with AI" while the button reads "AI Match"; wiki/jobs.md now matches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…wer / Prep List tabs and turn the current-stage accents green

An interview stage's detail panel now carries three underline tabs and
opens on Overview, which reads back outcome, location, format and
duration above the notes editor. Interviewers move to their own tab
(StageInterviewersTab), and the prep list's header line gives up its
asked tally to the Prep List tab trigger. A non-interview stage has
nothing to say on the other two tabs, so it renders the overview alone
with no tab row.

Selecting another stage re-renders the panel rather than remounting it,
so the tab resets on stage.id — otherwise an interview tab survives
onto a stage that has none.

The current stage now reads emerald rather than primary blue in all
four places it is marked: the history marker and its ring, the stage
label, the date, and the CURRENT STAGE badge.

wiki/timeline.md follows the panel-to-tabs rename in five passages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…th the TagInput, and reorder the job-details tabs

The Prep List tab now reads a question's answer without leaving the page:
an answered row is a toggle that expands the stored answer through
TipTapContentViewer, with an Expand all control on the header. A question
still holding the "TBD" placeholder reads "No answer yet" instead.

The answer was not reaching the client on first paint. JOB_DETAILS_INCLUDE
duplicates STAGE_DETAIL_INCLUDE by design, so both selects had to carry
Question.answer; without the queries.ts half, answers only appeared after a
stage write triggered a refetch.

The Add Questions dialog swaps its single-category creatable Combobox for the
shared TagInput, so a new bank question can carry several skill tags, and
widens to fit the tag row.

Job details tab order becomes Description, Timeline, AI Match, Cover Letter,
Contacts, Notes.

Help pages for both features updated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ts in the stage delete confirmation, and let DeleteAlertDialog take a node

Deleting a stage cascades its JobStageInterviewer and JobStagePrepQuestion
rows, taking the asked/not-asked record with them. The confirmation said
nothing about that, so it now lists what goes as bullets with the counts in
bold, and states what survives: the contacts stay on the job's Contacts tab
and the questions stay in the bank.

DeleteAlertDialog's alertDescription widens from string to ReactNode for this,
and renders through asChild over a div — Radix's Description is a p, and a ul
cannot nest inside one. Existing string callers are unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…/duration/location to interview stages, and unblock future dates in the stage pickers

Changing the status from the job header, the Jobs-list badge, the Edit Job
dialog or MCP update_job appended a stage with occurredAt null, so the
Timeline read "—" for something that had just happened. appendStatusStage
now stamps new Date(), matching createFirstStage.

Format, duration and location are interview-only in the Add Stage dialog,
but nothing enforced that: values typed before switching the stage type
saved onto a non-interview stage, where the detail panel never showed them.
stageDataFrom now blanks the three unless the resolved type's parent status
is interview, and the Overview tab hides the facts row for a non-interview
stage. resolveStageTypeId becomes resolveStageType and returns that parent
status, which also gives the custom-name branch the ownership check the
picked-type branch already had.

The dialog's TIME control overflowed its grid column and clipped its step
buttons, so TimePicker gains the fullWidth prop DatePicker and ComboBox
already have. Add Stage prefills the date and time with now, and DatePicker's
forward bound now follows disableFuture instead of captionLayout — it capped
every dropdown-caption calendar at the current month, which blocked
scheduling an interview next month and dating a certification's expiry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… the Interviewer tab with its count

Update Status → Link Interviewers and the Interviewer tab's Link button both
read Add now, and the dialog they open is titled Add and Link Interviewers.
The Interviewer tab carries a count badge matching the Prep List tab's, hidden
at zero. wiki/timeline.md follows the new wording, including the greyed-out H2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…wer select, the Interviewer tab badge, and the reordered job-details tabs

updateJobFromNames now expects a Date on the appended stage's occurredAt
(f575a65), job.actions expects answer in the prep-question select and
StageDetailPanel matches the Interviewer tab by prefix now that it carries a
count badge (471c1b5, cc2c5f7), and JobDetails asserts Timeline directly
follows Description.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…terviewer writes atomic, keep dialog state across reloads, and scope the DatePicker and ComboBox changes

Review findings from PR #126.

Derived-status invariant:
- appendStatusStage read "no current stage" as "status changed", so every
  Edit Job save stamped a stage dated today onto a stage-less job. It now
  falls back to the job's own statusId.
- A stage type named after a job status is what the status menus resolve
  through, so its parent status is no longer editable; a custom type may
  still move, and the jobs sitting on it are re-derived when it does.
- createJobStageType refused nothing when the name was already taken under
  another status, reporting success having done nothing.
- Retyping an interview stage that still has interviewers or prep questions
  is refused with the counts: nothing renders them on a non-interview stage,
  and a stranded prep link blocks deleting the question.

Atomic writes:
- The job and its first stage were two statements, in createJobRecord and
  again in persistDiscoveredJob, so a failure between them left a committed
  job with no timeline. Both now nest the stage in the job.create.
- linkStageInterviewer wrote the stage link and the JobContact separately,
  leaving the job's Contacts roster disagreeing with the stage. One
  transaction now, with the role resolved outside it per D4.

Client state:
- LinkInterviewersDialog reset on the stage object, so each link reloaded
  the stages and wiped the search box, the linked count and the new-contact
  draft. Split in two: transient state resets on stage?.id, while linkedIds
  re-seeds from every reload so a failed link corrects itself.
- StagePrepList never cleared its optimistic entry, so a failed toggle left
  the checkbox showing a state the database did not hold.

Scope and reuse:
- JOB_DETAILS_INCLUDE copied STAGE_DETAIL_INCLUDE on the stated ground that
  it lives in a "use server" module, which jobStage/shared.ts is not.
- DatePicker's endMonth widened to ten years for every dropdown picker,
  reaching eight fields that had capped at the current month. Now opt-in
  through futureYears, passed only by AddStageDialog.
- Dropped the questionCategory case and its createTag import from ComboBox,
  orphaned when 471c1b5 moved question tagging to TagInput.

Specs and help:
- Five assertions encoded the old shapes; three tests added for the new
  stage-type guards.
- wiki/timeline.md documents both new refusals, condensed to stay inside
  the page's word cap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Gsync
Gsync merged commit e34d6a1 into dev Sep 21, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant