mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 18:23:47 +10:00
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:
+7
-1
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
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
@@ -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() }),
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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" }])(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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."),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"). |
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user