refactor!: contract the redesign's legacy fields

The expand steps of the redesign kept older app versions and API clients
working. This removes those compatibility paths:

- Entry date text (period/date) is written from the structured dates on
  every save; an edit to the text alone is overwritten.
- application.archived is dropped; archived rows close first. CSV import
  still reads the flag from older exports.
- resume_version.label is dropped; versions are named by kind and name.
- rejected is no longer accepted as a stage; remaining rows and history
  close first. CSV import still maps it to closed.

BREAKING CHANGE: older app versions and API clients that read or write
archived, version labels, the rejected stage or date text alone no
longer work. migrations/20260929063245_contract_redesign_legacy_fields
has a rollback.sql that restores the dropped columns.
This commit is contained in:
Amruth Pillai
2026-09-29 08:34:27 +02:00
parent 29ab0326b2
commit cbc76b03b1
32 changed files with 7412 additions and 395 deletions
+7 -1
View File
@@ -235,7 +235,13 @@ Today `cover_letter` has name, recipient (rich-text HTML), content (HTML), `styl
| M8 | `application.closed_reason`, `cover_letter_id`, `sent_*`, `requirements` | `rejected`/`archived` → `closed` | Reverse script |
| M9 | `cover_letter.sender_linked`, `design_linked`, `recipient_name`, `recipient_company`, `letter_date`, `layout`; `cover_letter_version` | Existing letters → freeform, unlinked | Additive |
Contract steps, not done without your approval: stop dual-writing `period`/`date`; drop `application.archived`; drop `resume_version.label`; drop `rejected` from the stage enum.
Contract steps (approved on 29 Sep 2026, migration `20260929063245_contract_redesign_legacy_fields`, with `rollback.sql`):
- **`period`/`date` are written from `dates` only.** The text stays in the data as printed output, rewritten from the dates on every save; an edit to the text alone is overwritten. Entries without dates (imports, older data) still get dates from their text.
- **`application.archived` is dropped.** Rows still archived are closed first. The list, bulk update, MCP tools and CSV export lose the flag; CSV import still reads it from older exports as closed.
- **`resume_version.label` is dropped.** Versions are named by `kind` and `name`.
- **`rejected` is no longer accepted as a stage.** Remaining rows and history close first; CSV import still reads it from older exports.
- Older app versions and API clients that relied on these stop working against this schema.
---
@@ -7,7 +7,6 @@ const application: Application = {
company: 'Müller, "Partners"',
role: "Engineer",
status: "interview",
archived: false,
closedReason: null,
coverLetterId: null,
sentResumeVersionId: null,
@@ -75,7 +74,6 @@ describe("application CSV export", () => {
"Stage History": "Applied (2026-08-03) → Interview (2026-08-12)",
Timeline: "2026-08-03: Applied\n2026-08-08: Called recruiter\n2026-08-12: Interview",
Contacts: "Ada (Recruiter, Referral)",
Archived: "false",
});
expect(application.activity[0]?.id).toBe("interview");
});
@@ -138,10 +136,10 @@ describe("application CSV export", () => {
describe("application export selection", () => {
const early = { ...application, id: "early", appliedAt: new Date("2026-08-02T23:59:59Z") };
const late = { ...application, id: "late", archived: true, appliedAt: new Date("2026-08-03T23:59:59Z") };
const late = { ...application, id: "late", status: "closed" as const, appliedAt: new Date("2026-08-03T23:59:59Z") };
const all = [early, application, late];
it("exports exactly current filtered rows, or all rows including archived", () => {
it("exports exactly current filtered rows, or all rows including closed ones", () => {
expect(selectApplicationsForExport(all, [application], { scope: "filtered" })).toEqual([application]);
expect(selectApplicationsForExport(all, [application], { scope: "all" })).toEqual(all);
});
+6 -5
View File
@@ -1,7 +1,7 @@
import type { ApplicationStatus, Contact } from "@reactive-resume/schema/applications/data";
import type { Application } from "./types";
import {
applicationStatusInputSchema,
applicationStatusSchema,
contactSchema,
INTERVIEW_KINDS,
STAGES,
@@ -207,7 +207,8 @@ export function mapCsvToApplications(table: string[][], mapping?: readonly (CsvF
// The confirmed match, else the automatic one.
const fieldFor = mapping ?? autoMapHeaders(headers);
const recognized = [...new Set(fieldFor.filter((f): f is CsvField => !!f))];
const isReactiveResumeExport = ["Stage History", "Timeline", "Archived", "Created At", "Updated At"].every((header) =>
// Older exports also carry an Archived column.
const isReactiveResumeExport = ["Stage History", "Timeline", "Created At", "Updated At"].every((header) =>
headers.includes(header),
);
@@ -224,7 +225,9 @@ export function mapCsvToApplications(table: string[][], mapping?: readonly (CsvF
if (!value) return;
if (field === "tags") record.tags = parseTags(value);
else if (field === "status") {
const parsed = applicationStatusInputSchema.safeParse(value.toLowerCase());
// Older exports may still say `rejected`, the stage `closed` replaced.
const stage = value.toLowerCase();
const parsed = applicationStatusSchema.safeParse(stage === "rejected" ? "closed" : stage);
if (parsed.success) record.status = parsed.data;
} else if (field === "stageEnteredAt") {
record.stageEnteredAt = dateOnly(value);
@@ -297,7 +300,6 @@ export function exportApplicationsCsv(applications: readonly Application[]): str
"Closed Reason",
"Stage History",
"Timeline",
"Archived",
"Created At",
"Updated At",
];
@@ -334,7 +336,6 @@ export function exportApplicationsCsv(applications: readonly Application[]): str
application.closedReason ?? "",
stages.map((entry) => `${stageLabel(entry.stage)} (${dateOnly(entry.at)})`).join(" → "),
timeline.map((entry) => `${dateOnly(entry.at)}: ${timelineText(entry)}`).join("\n"),
String(application.archived),
new Date(application.createdAt).toISOString(),
new Date(application.updatedAt).toISOString(),
];
@@ -1,9 +1,8 @@
import { orpc } from "@/libs/orpc/client";
// A single source of truth for the applications list query so the query key stays identical
// across the board (optimistic drag), the detail panel, and the route. We always fetch archived
// rows too and filter them per-view client-side, so "unarchive" has something to act on.
const LIST_INPUT = { includeArchived: true } as const;
// across the board (optimistic drag), the detail panel, and the route.
const LIST_INPUT = {} as const;
export const applicationsListQueryOptions = () => orpc.applications.list.queryOptions({ input: LIST_INPUT });
export const applicationsListQueryKey = () => orpc.applications.list.queryKey({ input: LIST_INPUT });
@@ -110,8 +110,7 @@ describe("ResumesCommandGroup", () => {
])("opens the %s list page from the root palette with Enter", async (page, keys, createLabel) => {
mockUseQueryData((entity) => {
if (entity === "resumes") return [{ id: "resume-1", name: "Evil Apricot Pike", slug: "apricot" }];
if (entity === "applications")
return [{ id: "application-1", company: "Umbrella", role: "Staff Engineer", archived: false }];
if (entity === "applications") return [{ id: "application-1", company: "Umbrella", role: "Staff Engineer" }];
if (entity === "threads")
return [{ id: "thread-1", title: "Cover letter rewrite", resumeName: "Product Resume" }];
return [];
@@ -133,8 +132,7 @@ describe("ResumesCommandGroup", () => {
])("keeps arrow-key navigation active on the %s list page", async (_page, keys, createLabel, itemLabel) => {
mockUseQueryData((entity) => {
if (entity === "resumes") return [{ id: "resume-1", name: "Evil Apricot Pike", slug: "apricot" }];
if (entity === "applications")
return [{ id: "application-1", company: "Umbrella", role: "Staff Engineer", archived: false }];
if (entity === "applications") return [{ id: "application-1", company: "Umbrella", role: "Staff Engineer" }];
if (entity === "threads")
return [{ id: "thread-1", title: "Cover letter rewrite", resumeName: "Product Resume" }];
return [];
@@ -201,9 +199,7 @@ describe("ResumesCommandGroup", () => {
it("loads applications on the applications page", () => {
useCommandPaletteStore.setState({ pages: ["applications"] });
mockUseQueryData((entity) =>
entity === "applications"
? [{ id: "application-1", company: "Umbrella", role: "Staff Engineer", archived: false }]
: [],
entity === "applications" ? [{ id: "application-1", company: "Umbrella", role: "Staff Engineer" }] : [],
);
renderGroup();
@@ -217,8 +213,8 @@ describe("ResumesCommandGroup", () => {
mockUseQueryData((entity) =>
entity === "applications"
? [
{ id: "application-1", company: "Umbrella", role: "Staff Engineer", archived: false },
{ id: "application-2", company: "Wayne Enterprises", role: "Product Engineer", archived: false },
{ id: "application-1", company: "Umbrella", role: "Staff Engineer" },
{ id: "application-2", company: "Wayne Enterprises", role: "Product Engineer" },
]
: [],
);
@@ -232,9 +228,7 @@ describe("ResumesCommandGroup", () => {
it("opens the selected application by id", () => {
useCommandPaletteStore.setState({ pages: ["applications"] });
mockUseQueryData((entity) =>
entity === "applications"
? [{ id: "application-1", company: "Umbrella", role: "Staff Engineer", archived: false }]
: [],
entity === "applications" ? [{ id: "application-1", company: "Umbrella", role: "Staff Engineer" }] : [],
);
renderGroup();
@@ -565,9 +565,9 @@ export const useResumeStore = create<ResumeStore>()(
}
fn(state.resume.data as WritableDraft<ResumeData>);
// The server keeps dates and their legacy text in step on save; doing the same here keeps its
// echo identical to the draft, so an autosave never reads as an outside change.
syncResumeDates(state.resume.data as ResumeData, currentResume.data);
// The server writes the date text from the dates on save; doing the same here keeps its echo
// identical to the draft, so an autosave never reads as an outside change.
syncResumeDates(state.resume.data as ResumeData);
state.saveStatus = "saving";
state.canUndo = state.undoStack.length > 0;
state.canRedo = state.redoStack.length > 0;
+16 -16
View File
@@ -559,7 +559,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
},
"period": {
"type": "string",
"description": "The overall period of time at the company, as text. When multiple roles are used, this should reflect the total tenure. Kept in sync from `dates`; prefer writing `dates`."
"description": "The overall period of time at the company, as text. When multiple roles are used, this should reflect the total tenure. Written from `dates` on every save; write `dates` instead."
},
"dates": {
"type": "object",
@@ -659,7 +659,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
},
"period": {
"type": "string",
"description": "The period of time this role was held, as text. Kept in sync from `dates`; prefer writing `dates`."
"description": "The period of time this role was held, as text. Written from `dates` on every save; write `dates` instead."
},
"dates": {
"type": "object",
@@ -822,7 +822,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
},
"period": {
"type": "string",
"description": "The period of time the education was obtained over, as text. Kept in sync from `dates`; prefer writing `dates`."
"description": "The period of time the education was obtained over, as text. Written from `dates` on every save; write `dates` instead."
},
"dates": {
"type": "object",
@@ -990,7 +990,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
},
"period": {
"type": "string",
"description": "The period of time the project was worked on, as text. Kept in sync from `dates`; prefer writing `dates`."
"description": "The period of time the project was worked on, as text. Written from `dates` on every save; write `dates` instead."
},
"dates": {
"type": "object",
@@ -1470,7 +1470,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
},
"date": {
"type": "string",
"description": "The date when the award was received, as text. Kept in sync from `dates`; prefer writing `dates`."
"description": "The date when the award was received, as text. Written from `dates` on every save; write `dates` instead."
},
"dates": {
"type": "object",
@@ -1639,7 +1639,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
},
"date": {
"type": "string",
"description": "The date when the certification was received, as text. Kept in sync from `dates`; prefer writing `dates`."
"description": "The date when the certification was received, as text. Written from `dates` on every save; write `dates` instead."
},
"dates": {
"type": "object",
@@ -1808,7 +1808,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
},
"date": {
"type": "string",
"description": "The date when the publication was published, as text. Kept in sync from `dates`; prefer writing `dates`."
"description": "The date when the publication was published, as text. Written from `dates` on every save; write `dates` instead."
},
"dates": {
"type": "object",
@@ -1977,7 +1977,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
},
"period": {
"type": "string",
"description": "The period of time the author was volunteered at the organization or company, as text. Kept in sync from `dates`; prefer writing `dates`."
"description": "The period of time the author was volunteered at the organization or company, as text. Written from `dates` on every save; write `dates` instead."
},
"dates": {
"type": "object",
@@ -2617,7 +2617,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
},
"period": {
"type": "string",
"description": "The overall period of time at the company, as text. When multiple roles are used, this should reflect the total tenure. Kept in sync from `dates`; prefer writing `dates`."
"description": "The overall period of time at the company, as text. When multiple roles are used, this should reflect the total tenure. Written from `dates` on every save; write `dates` instead."
},
"dates": {
"type": "object",
@@ -2717,7 +2717,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
},
"period": {
"type": "string",
"description": "The period of time this role was held, as text. Kept in sync from `dates`; prefer writing `dates`."
"description": "The period of time this role was held, as text. Written from `dates` on every save; write `dates` instead."
},
"dates": {
"type": "object",
@@ -2892,7 +2892,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
},
"period": {
"type": "string",
"description": "The period of time the education was obtained over, as text. Kept in sync from `dates`; prefer writing `dates`."
"description": "The period of time the education was obtained over, as text. Written from `dates` on every save; write `dates` instead."
},
"dates": {
"type": "object",
@@ -3072,7 +3072,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
},
"period": {
"type": "string",
"description": "The period of time the project was worked on, as text. Kept in sync from `dates`; prefer writing `dates`."
"description": "The period of time the project was worked on, as text. Written from `dates` on every save; write `dates` instead."
},
"dates": {
"type": "object",
@@ -3590,7 +3590,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
},
"date": {
"type": "string",
"description": "The date when the award was received, as text. Kept in sync from `dates`; prefer writing `dates`."
"description": "The date when the award was received, as text. Written from `dates` on every save; write `dates` instead."
},
"dates": {
"type": "object",
@@ -3771,7 +3771,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
},
"date": {
"type": "string",
"description": "The date when the certification was received, as text. Kept in sync from `dates`; prefer writing `dates`."
"description": "The date when the certification was received, as text. Written from `dates` on every save; write `dates` instead."
},
"dates": {
"type": "object",
@@ -3952,7 +3952,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
},
"date": {
"type": "string",
"description": "The date when the publication was published, as text. Kept in sync from `dates`; prefer writing `dates`."
"description": "The date when the publication was published, as text. Written from `dates` on every save; write `dates` instead."
},
"dates": {
"type": "object",
@@ -4133,7 +4133,7 @@ The full JSON Schema for Reactive Resume follows. You can also fetch the latest
},
"period": {
"type": "string",
"description": "The period of time the author was volunteered at the organization or company, as text. Kept in sync from `dates`; prefer writing `dates`."
"description": "The period of time the author was volunteered at the organization or company, as text. Written from `dates` on every save; write `dates` instead."
},
"dates": {
"type": "object",
+113 -199
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,18 @@
-- Contract steps for the redesign (§3.10): the closed stage replaced `archived` and `rejected`, and versions are
-- labelled by `kind`. Older app versions stop working against this schema. rollback.sql restores the columns.
-- Anything still marked archived or rejected closes first, so no application reopens when the flag goes.
UPDATE "application" SET "status" = 'closed', "closed_reason" = 'not-selected' WHERE "status" = 'rejected';--> statement-breakpoint
UPDATE "application" SET "status" = 'closed' WHERE "archived" = true AND "status" <> 'closed';--> statement-breakpoint
UPDATE "application" SET "activity" = (
SELECT jsonb_agg(
CASE WHEN entry->>'type' = 'stage' AND entry->>'stage' = 'rejected'
THEN jsonb_set(entry, '{stage}', '"closed"')
ELSE entry
END
ORDER BY position
)
FROM jsonb_array_elements("activity") WITH ORDINALITY AS items(entry, position)
)
WHERE "activity" @> '[{"type": "stage", "stage": "rejected"}]';--> statement-breakpoint
ALTER TABLE "application" DROP COLUMN "archived";--> statement-breakpoint
ALTER TABLE "resume_version" DROP COLUMN "label";
@@ -0,0 +1,15 @@
-- Restores the columns the contract step dropped, for older app versions. Closed applications without a reason
-- were archived ones; version labels come back from their kind.
ALTER TABLE "application" ADD COLUMN "archived" boolean DEFAULT false NOT NULL;
UPDATE "application" SET "archived" = true WHERE "status" = 'closed' AND "closed_reason" IS NULL;
ALTER TABLE "resume_version" ADD COLUMN "label" text DEFAULT '' NOT NULL;
UPDATE "resume_version" SET "label" = CASE "kind"
WHEN 'created' THEN 'Created'
WHEN 'import' THEN 'Imported'
WHEN 'auto' THEN 'Manual save'
WHEN 'before-restore' THEN 'Before restore'
WHEN 'restored' THEN 'Restored version'
WHEN 'ai' THEN 'AI edit'
WHEN 'sent' THEN 'Sent'
ELSE coalesce("name", '')
END;
File diff suppressed because it is too large Load Diff
+5 -10
View File
@@ -4,7 +4,6 @@ import * as schema from "@reactive-resume/db/schema";
import {
aiMetadataSchema,
applicationClosedReasonSchema,
applicationStatusInputSchema,
applicationStatusSchema,
applicationTimelineEntrySchema,
contactSchema,
@@ -40,7 +39,6 @@ const applicationSchema = createSelectSchema(schema.application, {
closedReason: applicationClosedReasonSchema
.nullable()
.describe("Why a closed application ended: not-selected, withdrew, accepted-other or no-response."),
archived: z.boolean().describe("Deprecated: the closed stage replaced it. Kept in step for older clients."),
resumeId: z.string().nullable().describe("The linked Reactive Resume, if any."),
coverLetterId: z.string().nullable().describe("The linked saved cover letter, if any."),
sentResumeVersionId: z
@@ -109,7 +107,7 @@ const editableSchema = applicationSchema.pick({
const createInputSchema = editableSchema.partial().extend({
company: applicationSchema.shape.company,
role: applicationSchema.shape.role,
status: applicationStatusInputSchema.optional(),
status: applicationStatusSchema.optional(),
closedReason: applicationClosedReasonSchema.nullable().optional(),
stageEnteredAt: timelineDateSchema.optional(),
});
@@ -120,10 +118,9 @@ export const applicationDto = {
.object({
status: applicationStatusSchema.optional(),
tags: z.array(z.string()).optional(),
includeArchived: z.boolean().optional().default(false),
})
.optional()
.default({ includeArchived: false }),
.default({}),
output: z.array(applicationSchema.omit({ userId: true })),
},
@@ -146,12 +143,11 @@ export const applicationDto = {
update: {
input: editableSchema.partial().extend({
id: z.string(),
status: applicationStatusInputSchema.optional(),
status: applicationStatusSchema.optional(),
closedReason: applicationClosedReasonSchema
.nullable()
.optional()
.describe("Why the application closed; kept when status stays closed, cleared by any other stage."),
archived: z.boolean().optional(),
}),
output: applicationSchema.omit({ userId: true }),
},
@@ -225,13 +221,12 @@ export const applicationDto = {
output: z.void(),
},
// Table bulk actions: move stage, archive/unarchive, add tags across a selection.
// Table bulk actions: move stage, close, add tags across a selection.
bulkUpdate: {
input: z.object({
ids: z.array(z.string()).min(1).max(200, "Too many items in a single bulk operation"),
status: applicationStatusInputSchema.optional(),
status: applicationStatusSchema.optional(),
closedReason: applicationClosedReasonSchema.nullable().optional(),
archived: z.boolean().optional(),
addTags: z.array(z.string()).optional(),
}),
output: z.object({ updated: z.number() }),
-1
View File
@@ -31,7 +31,6 @@ const versionSchema = z.object({
"What made the version: created, import, auto (an editing session's autosave), named, before-restore, restored, ai (an AI or API edit) or sent.",
),
name: z.string().nullable().describe("The user's name for a named version."),
label: z.string().describe("A short English description of the version."),
createdAt: z.date().describe("When this state was saved."),
});
@@ -12,7 +12,7 @@ export const crudRouter = {
operationId: "listApplications",
summary: "List job applications",
description:
"Returns all job applications belonging to the authenticated user, most recently updated first. Archived applications are excluded unless includeArchived is set. Optionally filter by pipeline stage. Requires authentication.",
"Returns all job applications belonging to the authenticated user, most recently updated first. Optionally filter by pipeline stage. Requires authentication.",
successDescription: "A list of the user's job applications.",
})
.input(applicationDto.list.input)
@@ -22,7 +22,6 @@ export const crudRouter = {
userId: context.user.id,
...(input.status ? { status: input.status } : {}),
...(input.tags ? { tags: input.tags } : {}),
includeArchived: input.includeArchived,
}),
),
@@ -178,7 +178,7 @@ describe("applicationService.update", () => {
[Record<string, unknown>],
];
expect(closing).toMatchObject({ status: "closed", closedReason: "withdrew" });
expect(reopening).toMatchObject({ status: "applied", closedReason: null, archived: false });
expect(reopening).toMatchObject({ status: "applied", closedReason: null });
});
});
@@ -187,11 +187,11 @@ async function recordSentResume(row: ApplicationRow): Promise<ApplicationRow> {
return updated ?? row;
}
/** Closing keeps (or takes) a reason; any other stage clears it, and reopens an application that was archived. */
/** Closing keeps (or takes) a reason; any other stage clears it. */
function stageFields(status: ApplicationStatus | undefined, closedReason: ApplicationClosedReason | null | undefined) {
if (status === undefined) return closedReason !== undefined ? { closedReason } : {};
if (status === "closed") return closedReason !== undefined ? { closedReason } : {};
return { closedReason: null, archived: false };
return { closedReason: null };
}
async function assertOwnedResumes(userId: string, resumeIds: (string | null | undefined)[]) {
@@ -270,7 +270,7 @@ const stripUserId = <T extends { userId: string; activity?: ApplicationTimelineE
};
export const applicationService = {
list: async (input: { userId: string; status?: ApplicationStatus; tags?: string[]; includeArchived?: boolean }) => {
list: async (input: { userId: string; status?: ApplicationStatus; tags?: string[] }) => {
const rows = await db
.select()
.from(schema.application)
@@ -283,7 +283,7 @@ export const applicationService = {
)
.orderBy(desc(schema.application.updatedAt));
return rows.filter((row) => input.includeArchived || !row.archived).map(stripUserId);
return rows.map(stripUserId);
},
getById: async (input: { id: string; userId: string }) => {
@@ -364,12 +364,11 @@ export const applicationService = {
userId: string;
status?: ApplicationStatus | undefined;
closedReason?: ApplicationClosedReason | null | undefined;
archived?: boolean | undefined;
},
) => {
await requireOwned(input.id, input.userId);
const { id, userId, status, closedReason, archived, ...fields } = input;
const { id, userId, status, closedReason, ...fields } = input;
await assertOwnedResume(userId, fields.resumeId);
await assertOwnedCoverLetter(userId, fields.coverLetterId);
@@ -394,7 +393,6 @@ export const applicationService = {
...fields,
...(status !== undefined ? { status } : {}),
...(appliedAtExpr ? { appliedAt: appliedAtExpr } : {}),
...(archived !== undefined ? { archived } : {}),
...stageFields(status, closedReason),
...(activityExpr ? { activity: activityExpr } : {}),
})
@@ -675,7 +673,6 @@ export const applicationService = {
ids: string[];
status?: ApplicationStatus | undefined;
closedReason?: ApplicationClosedReason | null | undefined;
archived?: boolean | undefined;
addTags?: string[] | undefined;
}) => {
const scope = and(inArray(schema.application.id, input.ids), eq(schema.application.userId, input.userId));
@@ -713,7 +710,6 @@ export const applicationService = {
...(input.status !== undefined ? { status: input.status } : {}),
...(appliedAtExpr ? { appliedAt: appliedAtExpr } : {}),
...(activityExpr ? { activity: activityExpr } : {}),
...(input.archived !== undefined ? { archived: input.archived } : {}),
...stageFields(input.status, input.closedReason),
...(tagsExpr ? { tags: tagsExpr } : {}),
})
@@ -742,7 +738,7 @@ export const applicationService = {
// Raw counts for Insights; funnel/sankey/tiles are derived client-side from these.
stats: async (input: { userId: string }) => {
const scope = and(eq(schema.application.userId, input.userId), eq(schema.application.archived, false));
const scope = eq(schema.application.userId, input.userId);
const byStage = await db
.select({ status: schema.application.status, count: sql<number>`count(*)::int` })
@@ -22,15 +22,11 @@ function parseApiResumeData(data: unknown, code: "BAD_REQUEST" | "INTERNAL_SERVE
}
}
/**
* Validates data before it's saved, then keeps structured dates and their legacy text in step. `previous` is
* the stored data being replaced, which tells an edit to the text alone (from a client that doesn't know
* structured dates) from an edit to the dates.
*/
export const parseWritableResumeData = (data: unknown, previous?: ResumeData) => {
/** Validates data before it's saved, then writes each entry's date text from its structured dates. */
export const parseWritableResumeData = (data: unknown) => {
const parsed = parseApiResumeData(data, "BAD_REQUEST", "Resume data does not match the canonical schema.");
upgradeResumeDates(parsed);
syncResumeDates(parsed, previous);
syncResumeDates(parsed);
return parsed;
};
@@ -54,7 +54,6 @@ vi.mock("@reactive-resume/db/schema", () => ({
resumeId: "resume_id",
userId: "user_id",
data: "data",
label: "label",
createdAt: "created_at",
},
user: { id: "id", username: "username" },
+2 -6
View File
@@ -77,7 +77,6 @@ async function applyResumePatchTx(
userId: string;
operations: JsonPatchOperation[];
expectedUpdatedAt?: Date;
versionLabel?: string;
},
) {
const [existing] = await client
@@ -118,7 +117,7 @@ async function applyResumePatchTx(
});
}
patchedData = parseWritableResumeData(patchedData, storedData);
patchedData = parseWritableResumeData(patchedData);
// The version guard is the ms-precision JS check above, under the SELECT ... FOR UPDATE lock.
// Never compare expectedUpdatedAt in SQL: rows stamped by Postgres now() (defaultNow() on
// insert) carry microseconds, while JS Dates are ms-truncated — SQL equality then matches
@@ -154,7 +153,6 @@ async function applyResumePatchTx(
userId: input.userId,
data: resume.data,
kind: "ai",
...(input.versionLabel ? { label: input.versionLabel } : {}),
});
return resume;
@@ -609,9 +607,7 @@ export const resumeService = {
});
}
const normalizedData = input.data
? parseWritableResumeData(input.data, parseStoredResumeData(existing.data))
: undefined;
const normalizedData = input.data ? parseWritableResumeData(input.data) : undefined;
// A blank resume is named after its headline until someone names it by hand.
const followedName =
existing.autoName && input.name === undefined && normalizedData
@@ -16,7 +16,7 @@ const selectChain = (rows: unknown[]) => ({
const insertValues = () => {
const values = vi.fn((_input: Record<string, unknown>) => ({
returning: async () => [{ id: "v1", kind: _input.kind, name: _input.name, label: _input.label }],
returning: async () => [{ id: "v1", kind: _input.kind, name: _input.name }],
}));
dbMock.insert.mockReturnValue({ values });
return values;
@@ -40,26 +40,27 @@ beforeEach(() => {
});
describe("writeVersion", () => {
it("stores normalized data with its kind and an English label, then applies retention", async () => {
it("stores normalized data with its kind, then applies retention", async () => {
const values = insertValues();
const input = { ...data(), basics: { ...data().basics, name: "Ada" } };
await writeVersion(dbMock as never, { resumeId: "r1", userId: "u1", data: input, kind: "before-restore" });
expect(values).toHaveBeenCalledWith(
expect.objectContaining({ kind: "before-restore", label: "Before restore", name: null, sessionId: null }),
expect.objectContaining({ kind: "before-restore", name: null, sessionId: null }),
);
expect(values.mock.calls[0]?.[0]).toHaveProperty("data.basics.name", "Ada");
// Expired versions, then autosaves beyond the cap.
expect(dbMock.delete).toHaveBeenCalledTimes(2);
});
it("labels a named version with its name", async () => {
it("keeps a named version's name", async () => {
const values = insertValues();
await writeVersion(dbMock as never, { resumeId: "r1", userId: "u1", data: data(), kind: "named", name: "Sent" });
expect(values).toHaveBeenCalledWith(expect.objectContaining({ kind: "named", name: "Sent", label: "Sent" }));
expect(values).toHaveBeenCalledWith(expect.objectContaining({ kind: "named", name: "Sent" }));
expect(values.mock.calls[0]?.[0]).not.toHaveProperty("label");
});
});
@@ -19,22 +19,10 @@ const MAX_AUTOSAVES = 500;
// History shows at most this many versions, newest first.
const LIST_LIMIT = 100;
// `label` keeps an English description for API clients and older rows; the app labels versions by kind.
const LABELS: Record<Exclude<ResumeVersionKind, "named">, string> = {
created: "Created",
import: "Imported",
auto: "Manual save",
"before-restore": "Before restore",
restored: "Restored version",
ai: "AI edit",
sent: "Sent",
};
const summary = {
id: schema.resumeVersion.id,
kind: schema.resumeVersion.kind,
name: schema.resumeVersion.name,
label: schema.resumeVersion.label,
createdAt: schema.resumeVersion.createdAt,
};
@@ -44,7 +32,6 @@ type VersionInput = {
data: ResumeData;
kind: ResumeVersionKind;
name?: string;
label?: string;
sessionId?: string;
};
@@ -58,7 +45,6 @@ export async function writeVersion(client: DbOrTx, input: VersionInput) {
kind: input.kind,
name: input.name ?? null,
sessionId: input.sessionId ?? null,
label: input.label ?? (input.kind === "named" ? (input.name ?? "") : LABELS[input.kind]),
})
.returning(summary);
if (!version) throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to save the version." });
@@ -180,7 +166,7 @@ export async function getVersion(input: { resumeId: string; userId: string; vers
export async function renameVersion(input: { resumeId: string; userId: string; versionId: string; name: string }) {
const [version] = await db
.update(schema.resumeVersion)
.set({ name: input.name, label: input.name })
.set({ name: input.name })
.where(and(ownedVersion(input), eq(schema.resumeVersion.kind, "named")))
.returning(summary);
-2
View File
@@ -34,8 +34,6 @@ export const application = pg.pgTable(
status: pg.text("status").$type<ApplicationStatus>().notNull().default("saved"),
// Why a closed application ended (not selected, withdrew, accepted another offer, no response). Insights use it.
closedReason: pg.text("closed_reason").$type<ApplicationClosedReason>(),
// Superseded by the closed stage, which absorbed it; kept while older app versions may still read it.
archived: pg.boolean("archived").notNull().default(false),
// Live link to one of the user's resumes. Kept on resume delete (set null) so the
// application history survives.
resumeId: pg.text("resume_id").references(() => resume.id, { onDelete: "set null" }),
-2
View File
@@ -88,8 +88,6 @@ export const resumeVersion = pg.pgTable(
.references(() => user.id, { onDelete: "cascade" }),
// Snapshot of the resume data. A session's autosave is refreshed while the session lasts; the rest are immutable.
data: pg.jsonb("data").notNull().$type<ResumeData>(),
// English description kept for API clients and rows written before `kind`; the UI labels versions by `kind`.
label: pg.text("label").notNull(),
kind: pg.text("kind", { enum: RESUME_VERSION_KINDS }).notNull().default("auto"),
// The user's name for a `named` version.
name: pg.text("name"),
+2 -2
View File
@@ -102,12 +102,12 @@ describe("buildMcpServerCard", () => {
expect(props.apiKey).toBeDefined();
});
it("matches the create/update application archived contract", () => {
it("closes applications through their stage: no archived flag", () => {
const create = TOOL_META[MCP_TOOL_NAME.createApplication].inputSchema;
const update = TOOL_META[MCP_TOOL_NAME.updateApplication].inputSchema;
expect(create.safeParse({ company: "Acme", role: "Engineer", archived: true }).success).toBe(false);
expect(update.safeParse({ id: "app-1", archived: true }).success).toBe(true);
expect(update.parse({ id: "app-1", archived: true })).not.toHaveProperty("archived");
});
it.each([{ content: "Updated" }, { recipient: "Dear Hiring Manager" }, { template: "onyx" }])(
+1 -4
View File
@@ -312,7 +312,7 @@ export const TOOL_META = {
" year-months ('2022' or '2022-03')",
"",
"Dates: write `dates`; the text in `period` (or `date` for awards, certifications and",
"publications) is rewritten from it in the resume's locale. Editing only the text still works.",
"publications) is rewritten from it in the resume's locale, so an edit to the text alone is lost.",
"Important: HTML content fields (description, summary.content) must use valid HTML.",
"New items must include a valid UUID as `id` and `hidden: false`.",
`Locked resumes cannot be patched; use \`${T.unlockResume}\` first.`,
@@ -542,7 +542,6 @@ export const TOOL_META = {
inputSchema: z.object({
status: applicationStatusSchema.optional(),
tags: z.array(z.string()).optional().default([]),
includeArchived: z.boolean().optional().default(false),
}),
annotations: READ_IDEMPOTENT,
},
@@ -577,7 +576,6 @@ export const TOOL_META = {
inputSchema: z.object({
id: applicationIdSchema,
...applicationMutableFieldsSchema,
archived: z.boolean().optional().describe("Deprecated: close the application instead. Kept for older clients."),
}),
annotations: WRITE_DESTRUCTIVE,
},
@@ -652,7 +650,6 @@ export const TOOL_META = {
ids: z.array(z.string()).min(1),
status: applicationStatusSchema.optional(),
closedReason: applicationClosedReasonSchema.nullable().optional(),
archived: z.boolean().optional().describe("Deprecated: close the applications instead."),
addTags: z.array(z.string()).optional(),
}),
annotations: WRITE_DESTRUCTIVE,
+2 -2
View File
@@ -262,9 +262,9 @@ describe("registerTools", () => {
registerTools(server as never, clientMock as never, new Headers());
const tool = registered.find((item) => item.name === "list_applications")!;
const result = await tool.handler({ includeArchived: true, tags: ["remote"] });
const result = await tool.handler({ tags: ["remote"] });
expect(clientMock.applications.list).toHaveBeenCalledWith({ includeArchived: true, tags: ["remote"] });
expect(clientMock.applications.list).toHaveBeenCalledWith({ tags: ["remote"] });
expect(JSON.parse(result.content[0]!.text)).toEqual([{ id: "app-1", company: "Acme", role: "Engineer" }]);
});
+1 -8
View File
@@ -8,12 +8,6 @@ export const applicationStatusSchema = z.enum(APPLICATION_STATUSES);
export type ApplicationStatus = z.infer<typeof applicationStatusSchema>;
/** A stage as clients may send it: the retired `rejected` stage still arrives from older clients and means closed. */
export const applicationStatusInputSchema = z.preprocess(
(value) => (value === "rejected" ? "closed" : value),
applicationStatusSchema,
);
export const applicationClosedReasonSchema = z.enum(["not-selected", "withdrew", "accepted-other", "no-response"]);
export type ApplicationClosedReason = z.infer<typeof applicationClosedReasonSchema>;
@@ -79,8 +73,7 @@ export type InterviewDetails = z.infer<typeof interviewDetailsSchema>;
export const applicationTimelineEntrySchema = z.discriminatedUnion("type", [
timelineBaseSchema.extend({
type: z.literal("stage"),
// History written before the closed stage may still say `rejected`.
stage: applicationStatusInputSchema,
stage: applicationStatusSchema,
}),
timelineBaseSchema.extend({
type: z.literal("note"),
+12 -8
View File
@@ -137,7 +137,9 @@ export const awardItemSchema = baseItemSchema.extend({
awarder: z.string().describe("The awarder of the award."),
date: z
.string()
.describe("The date when the award was received, as text. Kept in sync from `dates`; prefer writing `dates`."),
.describe(
"The date when the award was received, as text. Written from `dates` on every save; write `dates` instead.",
),
dates: resumeDatesSchema.optional(),
website: itemWebsiteSchema.describe("The website of the award, if any."),
description: z.string().describe("The description of the award. This should be a HTML-formatted string."),
@@ -149,7 +151,7 @@ export const certificationItemSchema = baseItemSchema.extend({
date: z
.string()
.describe(
"The date when the certification was received, as text. Kept in sync from `dates`; prefer writing `dates`.",
"The date when the certification was received, as text. Written from `dates` on every save; write `dates` instead.",
),
dates: resumeDatesSchema.optional(),
website: itemWebsiteSchema.describe("The website of the certification, if any."),
@@ -167,7 +169,7 @@ export const educationItemSchema = baseItemSchema.extend({
period: z
.string()
.describe(
"The period of time the education was obtained over, as text. Kept in sync from `dates`; prefer writing `dates`.",
"The period of time the education was obtained over, as text. Written from `dates` on every save; write `dates` instead.",
),
dates: resumeDatesSchema.optional(),
website: itemWebsiteSchema.describe("The website of the school or institution, if any."),
@@ -179,7 +181,9 @@ const roleItemSchema = z.object({
position: z.string().describe("The position or job title for this role."),
period: z
.string()
.describe("The period of time this role was held, as text. Kept in sync from `dates`; prefer writing `dates`."),
.describe(
"The period of time this role was held, as text. Written from `dates` on every save; write `dates` instead.",
),
dates: resumeDatesSchema.optional(),
description: z.string().describe("The description of this specific role. This should be a HTML-formatted string."),
});
@@ -199,7 +203,7 @@ export const experienceItemSchema = baseItemSchema.extend({
period: z
.string()
.describe(
"The overall period of time at the company, as text. When multiple roles are used, this should reflect the total tenure. Kept in sync from `dates`; prefer writing `dates`.",
"The overall period of time at the company, as text. When multiple roles are used, this should reflect the total tenure. Written from `dates` on every save; write `dates` instead.",
),
dates: resumeDatesSchema.optional(),
website: itemWebsiteSchema.describe("The website of the company or organization, if any."),
@@ -254,7 +258,7 @@ export const projectItemSchema = baseItemSchema.extend({
period: z
.string()
.describe(
"The period of time the project was worked on, as text. Kept in sync from `dates`; prefer writing `dates`.",
"The period of time the project was worked on, as text. Written from `dates` on every save; write `dates` instead.",
),
dates: resumeDatesSchema.optional(),
website: itemWebsiteSchema.describe("The link to the project, if any."),
@@ -267,7 +271,7 @@ export const publicationItemSchema = baseItemSchema.extend({
date: z
.string()
.describe(
"The date when the publication was published, as text. Kept in sync from `dates`; prefer writing `dates`.",
"The date when the publication was published, as text. Written from `dates` on every save; write `dates` instead.",
),
dates: resumeDatesSchema.optional(),
website: itemWebsiteSchema.describe("The link to the publication, if any."),
@@ -321,7 +325,7 @@ export const volunteerItemSchema = baseItemSchema.extend({
period: z
.string()
.describe(
"The period of time the author was volunteered at the organization or company, as text. Kept in sync from `dates`; prefer writing `dates`.",
"The period of time the author was volunteered at the organization or company, as text. Written from `dates` on every save; write `dates` instead.",
),
dates: resumeDatesSchema.optional(),
website: itemWebsiteSchema.describe("The link to the organization or company, if any."),
+7 -7
View File
@@ -240,12 +240,12 @@ describe("syncResumeDates", () => {
if (!entry) throw new Error("Missing entry.");
entry.dates = { start: "2019-04", end: null, present: true };
syncResumeDates(next, previous);
syncResumeDates(next);
expect(job(next)?.period).toBe("Apr 2019 – Present");
});
it("reads the text again when only the text changed, as clients unaware of dates do", () => {
it("overwrites an edit to the text alone: the dates are the only source", () => {
const previous = parseResumeData(resume("2020 - 2022"));
syncResumeDates(previous);
const next = structuredClone(previous);
@@ -253,10 +253,10 @@ describe("syncResumeDates", () => {
if (!entry) throw new Error("Missing entry.");
entry.period = "Jan 2018 - Present";
syncResumeDates(next, previous);
syncResumeDates(next);
expect(job(next)?.dates).toEqual({ start: "2018-01", end: null, present: true });
expect(job(next)?.period).toBe("Jan 2018 – Present");
expect(job(next)?.dates).toEqual({ start: "2020", end: "2022", present: false });
expect(job(next)?.period).toBe("2020 – 2022");
});
it("reformats every entry when the locale or format changes, without reading the text again", () => {
@@ -266,7 +266,7 @@ describe("syncResumeDates", () => {
next.metadata.page.locale = "ja-JP";
next.metadata.page.dateFormat = "long";
syncResumeDates(next, previous);
syncResumeDates(next);
expect(job(next)?.dates).toEqual({ start: "2022-03", end: null, present: true });
expect(job(next)?.period).toBe(
@@ -278,7 +278,7 @@ describe("syncResumeDates", () => {
const data = resume("Summer 2016 - 2018");
syncResumeDates(data);
const once = structuredClone(data);
syncResumeDates(data, once);
syncResumeDates(data);
expect(job(data)?.period).toBe("Summer 2016 - 2018");
expect(job(data)?.dates?.raw).toBe("Summer 2016 - 2018");
+5 -33
View File
@@ -42,10 +42,6 @@ export const DEFAULT_DATE_FORMAT: DateFormat = "short";
export const EMPTY_RESUME_DATES: ResumeDates = { start: null, end: null, present: false };
/** Dates of the same entry are the same when their fields are; `raw` counts, since it changes what prints. */
export const areResumeDatesEqual = (a: ResumeDates | undefined, b: ResumeDates | undefined) =>
a?.start === b?.start && a?.end === b?.end && a?.present === b?.present && a?.raw === b?.raw;
const labels: Record<string, string> = presentLabels;
/**
@@ -241,44 +237,20 @@ type PageDateOptions = { locale: string; dateFormat?: DateFormat | undefined };
export const formatEntryDates = (dates: ResumeDates | undefined, text: string, page: PageDateOptions) =>
dates ? formatResumeDates(dates, { locale: page.locale, format: page.dateFormat }) : text;
type Snapshot = { text: string; dates: ResumeDates | undefined };
const entryKey = (entry: DatedEntry) => (entry as { id?: string }).id;
/**
* Keeps structured dates and the legacy text (`period` or `date`) in step before resume data is saved, so
* older app versions and API clients that read the text keep working (the dual write).
*
* Dates are the source of truth: the text is rewritten from them in the resume's locale and date format.
* The one exception is an edit that changed only the text since `previous` (a client that doesn't know
* structured dates); then the text is read into new dates. Entries without dates get them from their text.
* Rewrites each entry's text (`period` or `date`) from its structured dates, in the resume's locale and date
* format. Dates are the only source: an edit to the text alone is overwritten. Entries without dates (new
* items from imports or older data) get them from their text first.
*
* Mutates `data` in place and writes only what changed, so it's safe inside an immer draft.
*/
export function syncResumeDates(data: ResumeData, previous?: ResumeData) {
export function syncResumeDates(data: ResumeData) {
const { locale, dateFormat } = data.metadata.page;
const options: DateFormatOptions = { locale, format: dateFormat, presentLabel: getPresentLabel(locale) };
const before = new Map<string, Snapshot>();
if (previous) {
forEachDatedEntry(previous, (entry, field) => {
const key = entryKey(entry);
if (key) before.set(key, { text: getLegacyDateText(entry, field), dates: entry.dates });
});
}
forEachDatedEntry(data, (entry, field) => {
const text = getLegacyDateText(entry, field);
const read = () => readLegacyDates(text, { locale, single: field === "date" });
if (!entry.dates) {
entry.dates = read();
} else if (text !== formatResumeDates(entry.dates, options)) {
const key = entryKey(entry);
const snapshot = key ? before.get(key) : undefined;
const onlyTextChanged = snapshot && snapshot.text !== text && areResumeDatesEqual(snapshot.dates, entry.dates);
if (onlyTextChanged) entry.dates = read();
}
entry.dates ??= readLegacyDates(text, { locale, single: field === "date" });
const formatted = formatResumeDates(entry.dates, options);
if (formatted === text) return;
+16 -16
View File
@@ -169,7 +169,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.experience.items[].company` | `string` | yes | — | The name of the company or organization. Empty while the entry is a draft, which isn't printed. |
| `sections.experience.items[].position` | `string` | yes | — | The position held at the company or organization. Used when there is only a single role. If multiple roles are provided in the 'roles' field, this serves as a summary title or can be left blank. |
| `sections.experience.items[].location` | `string` | yes | — | The location of the company or organization. |
| `sections.experience.items[].period` | `string` | yes | — | The overall period of time at the company, as text. When multiple roles are used, this should reflect the total tenure. Kept in sync from `dates`; prefer writing `dates`. |
| `sections.experience.items[].period` | `string` | yes | — | The overall period of time at the company, as text. When multiple roles are used, this should reflect the total tenure. Written from `dates` on every save; write `dates` instead. |
| `sections.experience.items[].dates` | `object` | no | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `sections.experience.items[].dates.start` | `string or null` | yes | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `sections.experience.items[].dates.start` | `string` | yes (variant 1 at sections.experience.items[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
@@ -188,7 +188,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.experience.items[].roles[]` | `object` | — | — | — |
| `sections.experience.items[].roles[].id` | `string` | yes | — | The unique identifier for the role. Usually generated as a UUID. |
| `sections.experience.items[].roles[].position` | `string` | yes | — | The position or job title for this role. |
| `sections.experience.items[].roles[].period` | `string` | yes | — | The period of time this role was held, as text. Kept in sync from `dates`; prefer writing `dates`. |
| `sections.experience.items[].roles[].period` | `string` | yes | — | The period of time this role was held, as text. Written from `dates` on every save; write `dates` instead. |
| `sections.experience.items[].roles[].dates` | `object` | no | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `sections.experience.items[].roles[].dates.start` | `string or null` | yes | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `sections.experience.items[].roles[].dates.start` | `string` | yes (variant 1 at sections.experience.items[].roles[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
@@ -216,7 +216,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.education.items[].area` | `string` | yes | — | The area of study or specialization. |
| `sections.education.items[].grade` | `string` | yes | — | The grade or score achieved. |
| `sections.education.items[].location` | `string` | yes | — | The location of the school or institution. |
| `sections.education.items[].period` | `string` | yes | — | The period of time the education was obtained over, as text. Kept in sync from `dates`; prefer writing `dates`. |
| `sections.education.items[].period` | `string` | yes | — | The period of time the education was obtained over, as text. Written from `dates` on every save; write `dates` instead. |
| `sections.education.items[].dates` | `object` | no | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `sections.education.items[].dates.start` | `string or null` | yes | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `sections.education.items[].dates.start` | `string` | yes (variant 1 at sections.education.items[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
@@ -244,7 +244,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.projects.items[].id` | `string` | yes | — | The unique identifier for the item. Usually generated as a UUID. |
| `sections.projects.items[].hidden` | `boolean` | yes | — | Whether to hide the item from the resume. |
| `sections.projects.items[].name` | `string` | yes | — | The name of the project. Empty while the entry is a draft, which isn't printed. |
| `sections.projects.items[].period` | `string` | yes | — | The period of time the project was worked on, as text. Kept in sync from `dates`; prefer writing `dates`. |
| `sections.projects.items[].period` | `string` | yes | — | The period of time the project was worked on, as text. Written from `dates` on every save; write `dates` instead. |
| `sections.projects.items[].dates` | `object` | no | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `sections.projects.items[].dates.start` | `string or null` | yes | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `sections.projects.items[].dates.start` | `string` | yes (variant 1 at sections.projects.items[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
@@ -326,7 +326,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.awards.items[].hidden` | `boolean` | yes | — | Whether to hide the item from the resume. |
| `sections.awards.items[].title` | `string` | yes | — | The title of the award. Empty while the entry is a draft, which isn't printed. |
| `sections.awards.items[].awarder` | `string` | yes | — | The awarder of the award. |
| `sections.awards.items[].date` | `string` | yes | — | The date when the award was received, as text. Kept in sync from `dates`; prefer writing `dates`. |
| `sections.awards.items[].date` | `string` | yes | — | The date when the award was received, as text. Written from `dates` on every save; write `dates` instead. |
| `sections.awards.items[].dates` | `object` | no | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `sections.awards.items[].dates.start` | `string or null` | yes | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `sections.awards.items[].dates.start` | `string` | yes (variant 1 at sections.awards.items[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
@@ -355,7 +355,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.certifications.items[].hidden` | `boolean` | yes | — | Whether to hide the item from the resume. |
| `sections.certifications.items[].title` | `string` | yes | — | The title of the certification. Empty while the entry is a draft, which isn't printed. |
| `sections.certifications.items[].issuer` | `string` | yes | — | The issuer of the certification. |
| `sections.certifications.items[].date` | `string` | yes | — | The date when the certification was received, as text. Kept in sync from `dates`; prefer writing `dates`. |
| `sections.certifications.items[].date` | `string` | yes | — | The date when the certification was received, as text. Written from `dates` on every save; write `dates` instead. |
| `sections.certifications.items[].dates` | `object` | no | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `sections.certifications.items[].dates.start` | `string or null` | yes | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `sections.certifications.items[].dates.start` | `string` | yes (variant 1 at sections.certifications.items[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
@@ -384,7 +384,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.publications.items[].hidden` | `boolean` | yes | — | Whether to hide the item from the resume. |
| `sections.publications.items[].title` | `string` | yes | — | The title of the publication. Empty while the entry is a draft, which isn't printed. |
| `sections.publications.items[].publisher` | `string` | yes | — | The publisher of the publication. |
| `sections.publications.items[].date` | `string` | yes | — | The date when the publication was published, as text. Kept in sync from `dates`; prefer writing `dates`. |
| `sections.publications.items[].date` | `string` | yes | — | The date when the publication was published, as text. Written from `dates` on every save; write `dates` instead. |
| `sections.publications.items[].dates` | `object` | no | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `sections.publications.items[].dates.start` | `string or null` | yes | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `sections.publications.items[].dates.start` | `string` | yes (variant 1 at sections.publications.items[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
@@ -413,7 +413,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `sections.volunteer.items[].hidden` | `boolean` | yes | — | Whether to hide the item from the resume. |
| `sections.volunteer.items[].organization` | `string` | yes | — | The name of the organization or company. Empty while the entry is a draft, which isn't printed. |
| `sections.volunteer.items[].location` | `string` | yes | — | The location of the organization or company. |
| `sections.volunteer.items[].period` | `string` | yes | — | The period of time the author was volunteered at the organization or company, as text. Kept in sync from `dates`; prefer writing `dates`. |
| `sections.volunteer.items[].period` | `string` | yes | — | The period of time the author was volunteered at the organization or company, as text. Written from `dates` on every save; write `dates` instead. |
| `sections.volunteer.items[].dates` | `object` | no | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `sections.volunteer.items[].dates.start` | `string or null` | yes | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `sections.volunteer.items[].dates.start` | `string` | yes (variant 1 at sections.volunteer.items[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
@@ -507,7 +507,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].items[].company` | `string` | yes (type experience, schema experienceItemSchema at customSections[]) | — | The name of the company or organization. Empty while the entry is a draft, which isn't printed. |
| `customSections[].items[].position` | `string` | yes (type experience, schema experienceItemSchema at customSections[]) | — | The position held at the company or organization. Used when there is only a single role. If multiple roles are provided in the 'roles' field, this serves as a summary title or can be left blank. |
| `customSections[].items[].location` | `string` | yes (type experience, schema experienceItemSchema at customSections[]) | — | The location of the company or organization. |
| `customSections[].items[].period` | `string` | yes (type experience, schema experienceItemSchema at customSections[]) | — | The overall period of time at the company, as text. When multiple roles are used, this should reflect the total tenure. Kept in sync from `dates`; prefer writing `dates`. |
| `customSections[].items[].period` | `string` | yes (type experience, schema experienceItemSchema at customSections[]) | — | The overall period of time at the company, as text. When multiple roles are used, this should reflect the total tenure. Written from `dates` on every save; write `dates` instead. |
| `customSections[].items[].dates` | `object` | no (type experience, schema experienceItemSchema at customSections[]) | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `customSections[].items[].dates.start` | `string or null` | yes (type experience, schema experienceItemSchema at customSections[]) | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `customSections[].items[].dates.start` | `string` | yes (type experience, schema experienceItemSchema at customSections[]; variant 1 at customSections[].items[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
@@ -526,7 +526,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].items[].roles[]` | `object` | — (type experience, schema experienceItemSchema at customSections[]) | — | — |
| `customSections[].items[].roles[].id` | `string` | yes (type experience, schema experienceItemSchema at customSections[]) | — | The unique identifier for the role. Usually generated as a UUID. |
| `customSections[].items[].roles[].position` | `string` | yes (type experience, schema experienceItemSchema at customSections[]) | — | The position or job title for this role. |
| `customSections[].items[].roles[].period` | `string` | yes (type experience, schema experienceItemSchema at customSections[]) | — | The period of time this role was held, as text. Kept in sync from `dates`; prefer writing `dates`. |
| `customSections[].items[].roles[].period` | `string` | yes (type experience, schema experienceItemSchema at customSections[]) | — | The period of time this role was held, as text. Written from `dates` on every save; write `dates` instead. |
| `customSections[].items[].roles[].dates` | `object` | no (type experience, schema experienceItemSchema at customSections[]) | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `customSections[].items[].roles[].dates.start` | `string or null` | yes (type experience, schema experienceItemSchema at customSections[]) | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `customSections[].items[].roles[].dates.start` | `string` | yes (type experience, schema experienceItemSchema at customSections[]; variant 1 at customSections[].items[].roles[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
@@ -557,7 +557,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].items[].area` | `string` | yes (type education, schema educationItemSchema at customSections[]) | — | The area of study or specialization. |
| `customSections[].items[].grade` | `string` | yes (type education, schema educationItemSchema at customSections[]) | — | The grade or score achieved. |
| `customSections[].items[].location` | `string` | yes (type education, schema educationItemSchema at customSections[]) | — | The location of the school or institution. |
| `customSections[].items[].period` | `string` | yes (type education, schema educationItemSchema at customSections[]) | — | The period of time the education was obtained over, as text. Kept in sync from `dates`; prefer writing `dates`. |
| `customSections[].items[].period` | `string` | yes (type education, schema educationItemSchema at customSections[]) | — | The period of time the education was obtained over, as text. Written from `dates` on every save; write `dates` instead. |
| `customSections[].items[].dates` | `object` | no (type education, schema educationItemSchema at customSections[]) | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `customSections[].items[].dates.start` | `string or null` | yes (type education, schema educationItemSchema at customSections[]) | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `customSections[].items[].dates.start` | `string` | yes (type education, schema educationItemSchema at customSections[]; variant 1 at customSections[].items[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
@@ -588,7 +588,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].items[].id` | `string` | yes (type projects, schema projectItemSchema at customSections[]) | — | The unique identifier for the item. Usually generated as a UUID. |
| `customSections[].items[].hidden` | `boolean` | yes (type projects, schema projectItemSchema at customSections[]) | — | Whether to hide the item from the resume. |
| `customSections[].items[].name` | `string` | yes (type projects, schema projectItemSchema at customSections[]) | — | The name of the project. Empty while the entry is a draft, which isn't printed. |
| `customSections[].items[].period` | `string` | yes (type projects, schema projectItemSchema at customSections[]) | — | The period of time the project was worked on, as text. Kept in sync from `dates`; prefer writing `dates`. |
| `customSections[].items[].period` | `string` | yes (type projects, schema projectItemSchema at customSections[]) | — | The period of time the project was worked on, as text. Written from `dates` on every save; write `dates` instead. |
| `customSections[].items[].dates` | `object` | no (type projects, schema projectItemSchema at customSections[]) | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `customSections[].items[].dates.start` | `string or null` | yes (type projects, schema projectItemSchema at customSections[]) | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `customSections[].items[].dates.start` | `string` | yes (type projects, schema projectItemSchema at customSections[]; variant 1 at customSections[].items[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
@@ -680,7 +680,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].items[].hidden` | `boolean` | yes (type awards, schema awardItemSchema at customSections[]) | — | Whether to hide the item from the resume. |
| `customSections[].items[].title` | `string` | yes (type awards, schema awardItemSchema at customSections[]) | — | The title of the award. Empty while the entry is a draft, which isn't printed. |
| `customSections[].items[].awarder` | `string` | yes (type awards, schema awardItemSchema at customSections[]) | — | The awarder of the award. |
| `customSections[].items[].date` | `string` | yes (type awards, schema awardItemSchema at customSections[]) | — | The date when the award was received, as text. Kept in sync from `dates`; prefer writing `dates`. |
| `customSections[].items[].date` | `string` | yes (type awards, schema awardItemSchema at customSections[]) | — | The date when the award was received, as text. Written from `dates` on every save; write `dates` instead. |
| `customSections[].items[].dates` | `object` | no (type awards, schema awardItemSchema at customSections[]) | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `customSections[].items[].dates.start` | `string or null` | yes (type awards, schema awardItemSchema at customSections[]) | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `customSections[].items[].dates.start` | `string` | yes (type awards, schema awardItemSchema at customSections[]; variant 1 at customSections[].items[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
@@ -712,7 +712,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].items[].hidden` | `boolean` | yes (type certifications, schema certificationItemSchema at customSections[]) | — | Whether to hide the item from the resume. |
| `customSections[].items[].title` | `string` | yes (type certifications, schema certificationItemSchema at customSections[]) | — | The title of the certification. Empty while the entry is a draft, which isn't printed. |
| `customSections[].items[].issuer` | `string` | yes (type certifications, schema certificationItemSchema at customSections[]) | — | The issuer of the certification. |
| `customSections[].items[].date` | `string` | yes (type certifications, schema certificationItemSchema at customSections[]) | — | The date when the certification was received, as text. Kept in sync from `dates`; prefer writing `dates`. |
| `customSections[].items[].date` | `string` | yes (type certifications, schema certificationItemSchema at customSections[]) | — | The date when the certification was received, as text. Written from `dates` on every save; write `dates` instead. |
| `customSections[].items[].dates` | `object` | no (type certifications, schema certificationItemSchema at customSections[]) | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `customSections[].items[].dates.start` | `string or null` | yes (type certifications, schema certificationItemSchema at customSections[]) | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `customSections[].items[].dates.start` | `string` | yes (type certifications, schema certificationItemSchema at customSections[]; variant 1 at customSections[].items[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
@@ -744,7 +744,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].items[].hidden` | `boolean` | yes (type publications, schema publicationItemSchema at customSections[]) | — | Whether to hide the item from the resume. |
| `customSections[].items[].title` | `string` | yes (type publications, schema publicationItemSchema at customSections[]) | — | The title of the publication. Empty while the entry is a draft, which isn't printed. |
| `customSections[].items[].publisher` | `string` | yes (type publications, schema publicationItemSchema at customSections[]) | — | The publisher of the publication. |
| `customSections[].items[].date` | `string` | yes (type publications, schema publicationItemSchema at customSections[]) | — | The date when the publication was published, as text. Kept in sync from `dates`; prefer writing `dates`. |
| `customSections[].items[].date` | `string` | yes (type publications, schema publicationItemSchema at customSections[]) | — | The date when the publication was published, as text. Written from `dates` on every save; write `dates` instead. |
| `customSections[].items[].dates` | `object` | no (type publications, schema publicationItemSchema at customSections[]) | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `customSections[].items[].dates.start` | `string or null` | yes (type publications, schema publicationItemSchema at customSections[]) | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `customSections[].items[].dates.start` | `string` | yes (type publications, schema publicationItemSchema at customSections[]; variant 1 at customSections[].items[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
@@ -776,7 +776,7 @@ Choose one coherent shape for each union value. Required fields are local to tha
| `customSections[].items[].hidden` | `boolean` | yes (type volunteer, schema volunteerItemSchema at customSections[]) | — | Whether to hide the item from the resume. |
| `customSections[].items[].organization` | `string` | yes (type volunteer, schema volunteerItemSchema at customSections[]) | — | The name of the organization or company. Empty while the entry is a draft, which isn't printed. |
| `customSections[].items[].location` | `string` | yes (type volunteer, schema volunteerItemSchema at customSections[]) | — | The location of the organization or company. |
| `customSections[].items[].period` | `string` | yes (type volunteer, schema volunteerItemSchema at customSections[]) | — | The period of time the author was volunteered at the organization or company, as text. Kept in sync from `dates`; prefer writing `dates`. |
| `customSections[].items[].period` | `string` | yes (type volunteer, schema volunteerItemSchema at customSections[]) | — | The period of time the author was volunteered at the organization or company, as text. Written from `dates` on every save; write `dates` instead. |
| `customSections[].items[].dates` | `object` | no (type volunteer, schema volunteerItemSchema at customSections[]) | — | Structured dates. Write these rather than the legacy text field (`period` or `date`), which is kept in sync from them. |
| `customSections[].items[].dates.start` | `string or null` | yes (type volunteer, schema volunteerItemSchema at customSections[]) | default: null | When it started, or the date itself for single-date entries (awards, certifications, publications). |
| `customSections[].items[].dates.start` | `string` | yes (type volunteer, schema volunteerItemSchema at customSections[]; variant 1 at customSections[].items[].dates.start) | — | A year ("2022") or a year and month ("2022-03"). |
+10 -10
View File
@@ -22,20 +22,20 @@ test("exports filtered or all owned applications with an inclusive date range",
String(testInfo.project.use.baseURL),
);
await otherContext.close();
for (const [email, company, date, archived] of [
[account.email, "Alpha Company", "2026-08-03T00:00:00Z", false],
[account.email, "Beta Archived", "2026-08-03T23:59:59Z", true],
[account.email, "Gamma Later", "2026-08-04T00:00:00Z", false],
[foreign.email, "Foreign Secret", "2026-08-03T12:00:00Z", false],
for (const [email, company, date, status] of [
[account.email, "Alpha Company", "2026-08-03T00:00:00Z", "applied"],
[account.email, "Beta Closed", "2026-08-03T23:59:59Z", "closed"],
[account.email, "Gamma Later", "2026-08-04T00:00:00Z", "applied"],
[foreign.email, "Foreign Secret", "2026-08-03T12:00:00Z", "applied"],
] as const) {
await pool.query(
`INSERT INTO application (id,user_id,company,role,status,applied_at,archived,notes,activity)
SELECT $1,id,$2,'Engineer','applied',$3,$4,$5,$6 FROM "user" WHERE email=$7`,
`INSERT INTO application (id,user_id,company,role,status,applied_at,notes,activity)
SELECT $1,id,$2,'Engineer',$4,$3,$5,$6 FROM "user" WHERE email=$7`,
[
randomUUID(),
company,
date,
archived,
status,
'Quoted "note"\nSecond line',
JSON.stringify([{ id: randomUUID(), type: "stage", stage: "applied", at: date }]),
email,
@@ -57,7 +57,7 @@ test("exports filtered or all owned applications with an inclusive date range",
if (!path) throw new Error("CSV download was not saved");
let csv = await readFile(path, "utf8");
expect(csv).toContain('"Alpha Company"');
expect(csv).not.toMatch(/Beta Archived|Gamma Later|Foreign Secret/);
expect(csv).not.toMatch(/Beta Closed|Gamma Later|Foreign Secret/);
expect(csv).toContain('"Applied (2026-08-03)"');
expect(csv).toContain('"Quoted ""note""\nSecond line"');
@@ -76,7 +76,7 @@ test("exports filtered or all owned applications with an inclusive date range",
if (!path) throw new Error("CSV download was not saved");
csv = await readFile(path, "utf8");
expect(csv).toContain('"Alpha Company"');
expect(csv).toContain('"Beta Archived"');
expect(csv).toContain('"Beta Closed"');
expect(csv).not.toMatch(/Gamma Later|Foreign Secret/);
await page.setViewportSize({ width: 390, height: 844 });
await page.reload();