feat(timeline): add the Job Stages Timeline — per-job stage history that drives job status, with interviewers and prep lists - #126
Merged
Merged
Conversation
…-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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a per-job stage timeline. A job now moves through user-extensible stages (Applied → Phone Screen → 2nd Technical Interview → Offer …), and
Job.statusIdis 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
JobStageType(per-user vocabulary, seeded fromJOB_STAGESat signup and by migration),JobStage,JobStageInterviewer,JobStagePrepQuestion. Adds a Withdrawn status.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(andapplied/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 MCPupdateJobFromNames. A status change appends a stage only when the parent status differs, so re-picking Interview while on "2nd Technical Interview" adds nothing.Security.
JobStageand both join tables have no ownership column.JobStagetraversesJob: { userId }, and the joins check both sides on write (stage via the job, contact/question viacreatedBy). Deleting a question that is on any prep list is blocked.UI
DeleteAlertDialognow accepts a node).Docs. New
wiki/timeline.mdhelp page;wiki/jobs.mdand the index updated.Notes for review
src/lib/jobs/sortStages.ts), deliberately not PrismaorderBy. SQLite sorts NULL first andnulls: "last"is unsupported.prisma/migrations/*_job_stages_timeline/migration.sql.Testing
e2e/timeline.spec.tscovers stage→status and interviewer→contacts.🤖 Generated with Claude Code