diff --git a/docs/site.md b/docs/site.md index c14e226..9607c12 100644 --- a/docs/site.md +++ b/docs/site.md @@ -86,10 +86,14 @@ or admin token. Never set it where the public can reach the site. ## The resume PDF `/resume.pdf` is drawn with pdfkit from the same `resumeData()` as `/resume` -(`src/lib/resume.ts`), so the two never disagree. It is one plain column so -applicant tracking systems read it in order, uses the brand TTFs in +(`src/lib/resume.ts`), so the two never disagree. It reads top to bottom so +applicant tracking systems take it in order, aims for one page (roles marked +`earlier` print as a short list; skills share a row with education and +projects), uses the brand TTFs in `src/assets/pdf-fonts`, and is rebuilt only when the notes behind it change. -The highlights row comes from the `/resume` Page text note's `stats`. +The highlights row and Selected projects come from the `/resume` Page text note's +`stats` and `projects`. Greg's Resume Interview answers live in +`tools/migrate/src/seed.ts`. ## Where each page lives diff --git a/packages/schema/src/types.ts b/packages/schema/src/types.ts index b4508aa..a46bb6a 100644 --- a/packages/schema/src/types.ts +++ b/packages/schema/src/types.ts @@ -281,6 +281,10 @@ export const experience = def({ focus: line({ title: "Focus", description: "One line under the role." }).optional(), bullets: strings({ title: "Bullets" }), tech: strings({ title: "Tech" }).optional(), + earlier: z + .boolean() + .meta({ title: "Earlier role", description: "Shows in the short Earlier list: title, company, dates and the focus line only." }) + .optional(), }), }); @@ -470,6 +474,7 @@ export const siteSettings = def({ tagline: line({ title: "Tagline" }), lockup: line({ title: "Lockup line" }), location: line({ title: "Location" }).optional(), + email: line({ title: "Email", description: "Printed on the resume PDF." }).optional(), availability: line({ title: "Availability", description: "Shown on the resume and Inquiries, like Open to freelance web work." }).optional(), socials: z .array(z.object({ label: line({ title: "Label" }), handle: line({ title: "Handle" }), url: z.url().meta({ title: "Link" }) })) @@ -501,6 +506,16 @@ export const page = def({ ) .meta({ title: "Highlights", description: "Big numbers on the page, like the resume highlights. Only real, checkable numbers." }) .optional(), + projects: z + .array( + z.object({ + title: line({ title: "Project" }), + detail: line({ title: "Detail", description: "One line with a result or number worth stating." }), + link: line({ title: "Link", description: "A page on this site, like /work/cosmos, or a full address." }).optional(), + }), + ) + .meta({ title: "Selected projects", description: "Personal projects the resume lists, like the homelab or this site." }) + .optional(), }), }); diff --git a/services/site/src/lib/resume-pdf.ts b/services/site/src/lib/resume-pdf.ts index cdbfe31..d6af089 100644 --- a/services/site/src/lib/resume-pdf.ts +++ b/services/site/src/lib/resume-pdf.ts @@ -1,6 +1,7 @@ /** * The printable resume. Built from the same ResumeData as the /resume page, - * laid out as one plain column so applicant tracking systems read it in order. + * laid out top to bottom (skills share a row with education and projects) so + * applicant tracking systems read it in order. * Paper colors: ink on white, with the brand gold and teal darkened for print. */ import PDFDocument from "pdfkit"; @@ -9,7 +10,7 @@ import cinzelSemi from "../assets/pdf-fonts/Cinzel-SemiBold.ttf?inline"; import montReg from "../assets/pdf-fonts/Montserrat-Regular.ttf?inline"; import montMed from "../assets/pdf-fonts/Montserrat-Medium.ttf?inline"; import montSemi from "../assets/pdf-fonts/Montserrat-SemiBold.ttf?inline"; -import { month, period, type ResumeData } from "./resume.ts"; +import { listOf, month, period, type ResumeData } from "./resume.ts"; const fromDataUrl = (u: string) => Buffer.from(u.slice(u.indexOf(",") + 1), "base64"); const FONTS = { @@ -26,8 +27,15 @@ const GOLD = "#a8842f"; const TEAL = "#0f7f78"; const RULE = "#d9cfb8"; -const PAGE = { size: "LETTER" as const, margin: 50 }; +const PAGE = { size: "LETTER" as const, margin: 36 }; const SITE = "gregkilmer.com"; +const TEXT = 9; + +/** A column of the page: where text starts and how wide it runs. */ +interface Col { + x: number; + w: number; +} export function renderResumePdf(r: ResumeData): Promise { const doc = new PDFDocument({ @@ -40,8 +48,7 @@ export function renderResumePdf(r: ResumeData): Promise { }); for (const [name, data] of Object.entries(FONTS)) doc.registerFont(name, data); - const left = doc.page.margins.left; - const width = doc.page.width - left - doc.page.margins.right; + const full: Col = { x: doc.page.margins.left, w: doc.page.width - doc.page.margins.left - doc.page.margins.right }; const chunks: Buffer[] = []; doc.on("data", (c: Buffer) => chunks.push(c)); const done = new Promise((resolve, reject) => { @@ -60,137 +67,177 @@ export function renderResumePdf(r: ResumeData): Promise { /** A thin gold rule with a small star at its center. */ const starRule = (y: number) => { - const mid = left + width / 2; + const mid = full.x + full.w / 2; doc.save().lineWidth(0.6).strokeColor(GOLD); - doc.moveTo(left, y).lineTo(mid - 10, y).stroke(); - doc.moveTo(mid + 10, y).lineTo(left + width, y).stroke(); + doc.moveTo(full.x, y).lineTo(mid - 10, y).stroke(); + doc.moveTo(mid + 10, y).lineTo(full.x + full.w, y).stroke(); doc.restore(); diamond(mid, y, 4); }; - const section = (title: string) => { - room(60); - doc.moveDown(0.75); + const section = (title: string, c: Col = full) => { + room(50); + doc.moveDown(0.5); const y = doc.y; - doc.font("heading").fontSize(11).fillColor(GOLD).text(title.toUpperCase(), left, y, { characterSpacing: 1.6 }); - const ruleY = doc.y + 2; - doc.save().lineWidth(0.5).strokeColor(RULE).moveTo(left, ruleY).lineTo(left + width, ruleY).stroke().restore(); - doc.y = ruleY + 8; + doc.font("heading").fontSize(10.5).fillColor(GOLD).text(title.toUpperCase(), c.x, y, { characterSpacing: 1.6, width: c.w }); + const ruleY = doc.y + 1; + doc.save().lineWidth(0.5).strokeColor(RULE).moveTo(c.x, ruleY).lineTo(c.x + c.w, ruleY).stroke().restore(); + doc.y = ruleY + 6; }; - const bullet = (text: string, indent = 12) => { - doc.font("body").fontSize(9.5); - const h = doc.heightOfString(text, { width: width - indent, lineGap: 1.5 }); - room(h + 4); + const bullet = (text: string, c: Col = full, lead?: string) => { + const indent = 11; + const w = c.w - indent; + doc.font("body").fontSize(TEXT); + room(doc.heightOfString(lead ? `${lead} ${text}` : text, { width: w, lineGap: 1.2 }) + 3); const y = doc.y; - diamond(left + 3.5, y + 6, 2.2); - doc.fillColor(INK).text(text, left + indent, y, { width: width - indent, lineGap: 1.5 }); - doc.moveDown(0.15); + diamond(c.x + 3.5, y + 5.5, 2.1); + if (lead) { + doc.font("strong").fillColor(INK).text(lead, c.x + indent, y, { width: w, continued: true, lineGap: 1.2 }); + doc.font("body").text(` ${text}`, { lineGap: 1.2 }); + } else { + doc.fillColor(INK).text(text, c.x + indent, y, { width: w, lineGap: 1.2 }); + } + doc.moveDown(0.12); + }; + + /** A title on the left with its dates set flush right on the same line. */ + const titled = (title: string, when: string, size: number, c: Col = full) => { + room(30); + const y = doc.y; + doc.font("medium").fontSize(8.5).fillColor(GOLD); + const whenW = when ? doc.widthOfString(when) : 0; + if (when) doc.text(when, c.x + c.w - whenW, y + (size - 8.5) / 2 + 0.5, { lineBreak: false }); + doc.font("strong").fontSize(size).fillColor(INK).text(title, c.x, y, { width: c.w - whenW - 12 }); }; // Header - doc.font("title").fontSize(28).fillColor(INK).text(r.name.toUpperCase(), left, doc.y, { characterSpacing: 2 }); - if (r.headline) doc.font("medium").fontSize(11).fillColor(TEAL).text(r.headline, { width }); + doc.font("title").fontSize(24).fillColor(INK).text(r.name.toUpperCase(), full.x, doc.y, { characterSpacing: 2 }); + if (r.availability) { + const y = doc.y; + doc.font("medium").fontSize(8.5).fillColor(INK); + const w = doc.widthOfString(r.availability); + doc.text(r.availability, full.x + full.w - w, y + 2.5, { lineBreak: false }); + doc.x = full.x; + doc.y = y; + } + if (r.headline) doc.font("medium").fontSize(10.5).fillColor(TEAL).text(r.headline, full.x, doc.y, { width: full.w - 160 }); // Only the professional profiles belong on paper; the hobby links stay on the site. const profiles = r.socials.filter((s) => /github|gitea|linkedin/i.test(`${s.label} ${s.url}`)); - const contact = [r.location, SITE, ...profiles.map((s) => s.url.replace(/^https?:\/\/(www\.)?/, "").replace(/\/$/, ""))].filter(Boolean).join(" · "); - doc.moveDown(0.3).font("body").fontSize(9).fillColor(MUTED).text(contact, { width }); - if (r.availability) doc.font("medium").fontSize(9).fillColor(INK).text(r.availability, { width }); + const contact = [r.location, r.email, SITE, ...profiles.map((s) => s.url.replace(/^https?:\/\/(www\.)?/, "").replace(/\/$/, ""))] + .filter(Boolean) + .join(" · "); + doc.moveDown(0.2).font("body").fontSize(8).fillColor(MUTED).text(contact, { width: full.w }); doc.moveDown(0.7); starRule(doc.y); - doc.moveDown(0.6); + doc.moveDown(0.5); if (r.summary) { - doc.font("body").fontSize(10).fillColor(INK).text(r.summary.replace(/\s*\n\s*/g, " "), left, doc.y, { width, lineGap: 2 }); + doc.font("body").fontSize(9.5).fillColor(INK).text(r.summary.replace(/\s*\n\s*/g, " "), full.x, doc.y, { width: full.w, lineGap: 1.6 }); } if (r.stats.length) { - section("Highlights"); + doc.moveDown(0.7); const cols = Math.min(r.stats.length, 4); - const colW = width / cols; - let top = doc.y; + const colW = full.w / cols; + const top = doc.y; + let bottom = top; r.stats.forEach((st, i) => { - if (i > 0 && i % cols === 0) top = doc.y + 6; - const x = left + (i % cols) * colW; - doc.font("title").fontSize(16).fillColor(GOLD).text(st.value, x, top, { width: colW - 10 }); - doc.font("strong").fontSize(9).fillColor(INK).text(st.label, x, doc.y, { width: colW - 10 }); - if (st.detail) doc.font("body").fontSize(8.5).fillColor(MUTED).text(st.detail, x, doc.y, { width: colW - 10 }); + const x = full.x + (i % cols) * colW; + const y = top + Math.floor(i / cols) * 44; + doc.font("title").fontSize(15).fillColor(GOLD).text(st.value, x, y, { width: colW - 10, lineBreak: false }); + doc.font("strong").fontSize(8.5).fillColor(INK).text(st.label, x, y + 18, { width: colW - 10 }); + if (st.detail) doc.font("body").fontSize(8).fillColor(MUTED).text(st.detail, x, doc.y, { width: colW - 10 }); + bottom = Math.max(bottom, doc.y); }); - doc.x = left; + doc.x = full.x; + doc.y = bottom; } if (r.roles.length) { section("Experience"); r.roles.forEach((role, i) => { const d = role.data; - if (i > 0) doc.moveDown(0.6); - room(64); - const y = doc.y; - const when = period(role); - doc.font("medium").fontSize(9).fillColor(GOLD); - const whenW = doc.widthOfString(when); - doc.text(when, left + width - whenW, y + 1.5, { lineBreak: false }); - doc.font("strong").fontSize(11).fillColor(INK).text(d.role, left, y, { width: width - whenW - 16 }); + if (i > 0) doc.moveDown(0.5); + titled(d.role, period(role), 10.5); const at = [d.company, d.location].filter(Boolean).join(" · "); - doc.font("medium").fontSize(9.5).fillColor(TEAL).text(at, left, doc.y, { width }); - if (d.previousRoles?.length) { - doc.font("body").fontSize(8.5).fillColor(MUTED).text(`Previously ${d.previousRoles.join(", ")} at ${d.company}`, { width }); - } - if (d.focus) doc.moveDown(0.2).font("body").fontSize(9).fillColor(MUTED).text(d.focus, { width, lineGap: 1 }); - doc.moveDown(0.3); + doc.font("medium").fontSize(9).fillColor(TEAL).text(at, full.x, doc.y, { width: full.w }); + if (d.previousRoles?.length) doc.font("body").fontSize(8.5).fillColor(MUTED).text(`Previously ${listOf(d.previousRoles)}`, { width: full.w }); + if (d.focus) doc.font("body").fontSize(8.5).fillColor(MUTED).text(d.focus, { width: full.w }); + doc.moveDown(0.25); for (const b of d.bullets ?? []) bullet(b); - if (d.tech?.length) { - doc.font("body").fontSize(8.5).fillColor(MUTED).text(d.tech.join(" · "), left + 12, doc.y, { width: width - 12 }); - } - doc.x = left; + doc.x = full.x; }); } - if (r.skills?.stack.length) { - section("Skills"); - for (const g of r.skills.stack) { - const items = g.items.join(", "); - doc.font("body").fontSize(9.5); - room(doc.heightOfString(items, { width: width - 110 }) + 4); + if (r.earlier.length) { + section("Earlier"); + r.earlier.forEach((role, i) => { + const d = role.data; + if (i > 0) doc.moveDown(0.3); + titled(d.role, period(role), 9.5); + doc.font("medium").fontSize(8.5).fillColor(TEAL).text(d.company, full.x, doc.y, { width: full.w }); + if (d.focus) doc.font("body").fontSize(8.5).fillColor(MUTED).text(d.focus, { width: full.w }); + }); + } + + // Skills on the left; education, certifications and projects on the right. + const gap = 22; + const leftCol: Col = { x: full.x, w: full.w * 0.55 }; + const rightCol: Col = { x: full.x + leftCol.w + gap, w: full.w - leftCol.w - gap }; + const labelW = 64; + const skillRows = (r.skills?.stack ?? []).map((g) => ({ label: g.label, items: g.items.join(", ") })); + // Measure both columns first: the block moves to a new page whole rather than splitting. + doc.fontSize(8.5); + const leftH = skillRows.reduce((h, g) => { + doc.font("strong"); + const lh = doc.heightOfString(g.label, { width: labelW }); + doc.font("body"); + return h + Math.max(lh, doc.heightOfString(g.items, { width: leftCol.w - labelW, lineGap: 0.8 })) + 2; + }, 30); + const entries = (r.education?.degrees.length ?? 0) + (r.education?.certifications?.length ?? 0); + doc.font("body"); + const rightH = + (entries ? 26 + entries * 28 : 0) + + (r.projects.length ? 26 : 0) + + r.projects.reduce((h, p) => h + doc.heightOfString(`${p.title} ${p.detail}`, { width: rightCol.w - 11, lineGap: 1.2 }) + 3, 0); + if (doc.y + Math.max(leftH, rightH) > doc.page.height - doc.page.margins.bottom) doc.addPage(); + const start = doc.y; + let leftEnd = start; + + if (skillRows.length) { + section("Skills", leftCol); + for (const { label, items } of skillRows) { const y = doc.y; - doc.font("strong").fontSize(9.5).fillColor(INK).text(g.label, left, y, { width: 100 }); + doc.font("strong").fontSize(8.5).fillColor(INK).text(label, leftCol.x, y, { width: labelW }); const afterLabel = doc.y; - doc.font("body").fontSize(9.5).fillColor(INK).text(items, left + 110, y, { width: width - 110, lineGap: 1 }); - doc.y = Math.max(doc.y, afterLabel) + 3; + doc.font("body").fontSize(8.5).fillColor(INK).text(items, leftCol.x + labelW, y, { width: leftCol.w - labelW, lineGap: 0.8 }); + doc.y = Math.max(doc.y, afterLabel) + 2; } - if (r.skills.core?.length) { - room(30); - const y = doc.y; - doc.font("strong").fontSize(9.5).fillColor(INK).text("Strengths", left, y, { width: 100 }); - doc.font("body").fontSize(9.5).fillColor(INK).text(r.skills.core.join(", "), left + 110, y, { width: width - 110, lineGap: 1 }); - } - doc.x = left; + leftEnd = doc.y; } - if (r.education?.degrees.length) { - section("Education"); - for (const dg of r.education.degrees) { - room(40); - const y = doc.y; - doc.font("medium").fontSize(9).fillColor(GOLD); - const w = doc.widthOfString(dg.period); - doc.text(dg.period, left + width - w, y + 1.5, { lineBreak: false }); - doc.font("strong").fontSize(10.5).fillColor(INK).text(dg.credential, left, y, { width: width - w - 16 }); - doc.font("medium").fontSize(9.5).fillColor(TEAL).text(dg.institution, left, doc.y, { width }); - if (dg.detail) doc.font("body").fontSize(9).fillColor(MUTED).text(dg.detail, { width }); - doc.moveDown(0.4); + doc.y = start; + const edu = r.education; + if (edu?.degrees.length || edu?.certifications?.length) { + section("Education", rightCol); + for (const dg of edu?.degrees ?? []) { + titled(dg.credential, dg.period, 9.5, rightCol); + doc.font("medium").fontSize(8.5).fillColor(TEAL).text(dg.institution, rightCol.x, doc.y, { width: rightCol.w }); + doc.moveDown(0.3); + } + for (const c of edu?.certifications ?? []) { + titled(c.name, c.year ?? "", 9.5, rightCol); + if (c.issuer) doc.font("medium").fontSize(8.5).fillColor(TEAL).text(c.issuer, rightCol.x, doc.y, { width: rightCol.w }); + doc.moveDown(0.3); } } - - if (r.education?.certifications?.length) { - section("Certifications"); - for (const c of r.education.certifications) { - room(20); - const line = [c.issuer, c.year].filter(Boolean).join(" · "); - doc.font("strong").fontSize(9.5).fillColor(INK).text(c.name, left, doc.y, { continued: !!line }); - if (line) doc.font("body").fillColor(MUTED).text(` ${line}`); - } + if (r.projects.length) { + section("Selected projects", rightCol); + for (const p of r.projects) bullet(p.detail, rightCol, p.title); } + doc.x = full.x; + doc.y = Math.max(doc.y, leftEnd); // Footer on every page. const range = doc.bufferedPageRange(); @@ -200,12 +247,12 @@ export function renderResumePdf(r: ResumeData): Promise { const y = doc.page.height - PAGE.margin + 4; const bottom = doc.page.margins.bottom; doc.page.margins.bottom = 0; - doc.save().lineWidth(0.5).strokeColor(RULE).moveTo(left, y - 8).lineTo(left + width, y - 8).stroke().restore(); + doc.save().lineWidth(0.5).strokeColor(RULE).moveTo(full.x, y - 8).lineTo(full.x + full.w, y - 8).stroke().restore(); doc.font("body").fontSize(8).fillColor(MUTED); - doc.text([`${SITE}/resume`, updated].filter(Boolean).join(" · "), left, y, { lineBreak: false }); + doc.text([`${SITE}/resume`, updated].filter(Boolean).join(" · "), full.x, y, { lineBreak: false }); if (range.count > 1) { const label = `${i - range.start + 1} of ${range.count}`; - doc.text(label, left + width - doc.widthOfString(label), y, { lineBreak: false }); + doc.text(label, full.x + full.w - doc.widthOfString(label), y, { lineBreak: false }); } doc.page.margins.bottom = bottom; } diff --git a/services/site/src/lib/resume.ts b/services/site/src/lib/resume.ts index 643b1dd..20a25f1 100644 --- a/services/site/src/lib/resume.ts +++ b/services/site/src/lib/resume.ts @@ -8,8 +8,13 @@ export interface ResumeData { location?: string; summary?: string; availability?: string; + email?: string; stats: { value: string; label: string; detail?: string }[]; + /** Roles shown in full, current first. */ roles: Note<"experience">[]; + /** Roles marked earlier: a short list of title, company, dates and focus. */ + earlier: Note<"experience">[]; + projects: { title: string; detail: string; link?: string }[]; skills?: Note<"skills">["data"]; education?: Note<"education">["data"]; socials: { label: string; handle: string; url: string }[]; @@ -26,6 +31,9 @@ export const month = (ym: string | undefined) => { export const period = (r: Note<"experience">) => `${month(r.data.start)} – ${month(r.data.end)}`; +/** "A, B and C". */ +export const listOf = (items: string[]) => new Intl.ListFormat("en-US", { style: "long", type: "conjunction" }).format(items); + export function resumeData(s: SiteIndex): ResumeData { const settings = s.settings(); const page = s.pageText("/resume"); @@ -39,8 +47,11 @@ export function resumeData(s: SiteIndex): ResumeData { location: settings?.location, summary: page?.body?.trim() || undefined, availability: settings?.availability, + email: settings?.email, stats: page?.data.stats ?? [], - roles, + roles: roles.filter((r) => !r.data.earlier), + earlier: roles.filter((r) => r.data.earlier), + projects: page?.data.projects ?? [], skills: s.one("skills")?.data, education: s.one("education")?.data, socials: settings?.socials ?? [], diff --git a/services/site/src/pages/resume.astro b/services/site/src/pages/resume.astro index 015042f..786aa49 100644 --- a/services/site/src/pages/resume.astro +++ b/services/site/src/pages/resume.astro @@ -15,7 +15,7 @@ import StarPath from "@gk/ui/components/StarPath.astro"; import TickRing from "@gk/ui/components/TickRing.astro"; import TickScale from "@gk/ui/components/TickScale.astro"; import { site } from "../lib/page.ts"; -import { period, resumeData } from "../lib/resume.ts"; +import { listOf, period, resumeData } from "../lib/resume.ts"; import { formatDate } from "../lib/site.ts"; const s = await site(); @@ -167,7 +167,7 @@ const isCore = (group: { core?: string[] }, item: string) => group.core?.some((c {role.data.company}{role.data.location ? ` · ${role.data.location}` : ""} {role.data.previousRoles?.length ? ( - Previously {role.data.previousRoles.join(" and ")} at {role.data.company} + Previously {listOf(role.data.previousRoles)} ) : null} {role.data.focus && {role.data.focus}}
    @@ -189,6 +189,43 @@ const isCore = (group: { core?: string[] }, item: string) => group.core?.some((c )) } + { + r.earlier.length > 0 && ( +
    +

    Earlier

    +
      + {r.earlier.map((role) => ( +
    • +
      + {role.data.role} + {period(role)} +
      + {role.data.company} + {role.data.focus && {role.data.focus}} +
    • + ))} +
    +
    + ) + } + { + r.projects.length > 0 && ( +
    +

    Selected projects

    +
      + {r.projects.map((p) => ( +
    • + + + {p.link ? {p.title} : {p.title}} + · {p.detail} + +
    • + ))} +
    +
    + ) + } {caseStudies.length > 0 && } @@ -556,4 +593,61 @@ const isCore = (group: { core?: string[] }, item: string) => group.core?.some((c .role li :global(svg) { margin-top: 7px; } + .earlier, + .projects { + display: flex; + flex-direction: column; + gap: 12px; + } + .sub-head { + margin: 8px 0 0; + font-size: 16px; + letter-spacing: 0.08em; + color: var(--gk-gold-bright); + } + .earlier ul, + .projects ul { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 12px; + } + .earlier li { + display: flex; + flex-direction: column; + gap: 4px; + padding: 14px 18px; + border: 1px solid var(--gk-lines); + border-radius: 10px; + background: var(--gk-midnight); + scroll-margin-top: 24px; + } + .earlier-role { + font-family: var(--gk-font-display); + font-size: 15px; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--gk-text); + } + .earlier .company { + font-size: 14px; + } + .projects li { + display: flex; + gap: 10px; + font-size: 15px; + line-height: 1.55; + color: var(--gk-soft); + } + .projects li :global(svg) { + flex: none; + margin-top: 6px; + } + .projects strong, + .projects a { + color: var(--gk-text); + font-weight: 600; + } diff --git a/tools/migrate/src/build.ts b/tools/migrate/src/build.ts index cf7d449..500f984 100644 --- a/tools/migrate/src/build.ts +++ b/tools/migrate/src/build.ts @@ -263,13 +263,15 @@ export function build(legacyDir: string): BuildResult { }; for (const file of readdirSync(expDir).sort()) { const { frontmatter: e, body } = splitNote(readFileSync(join(expDir, file), "utf8")) as { frontmatter: any; body: string }; + // Greg's Resume Interview answers replace the legacy wording field by field. + const answers = seed.experience[basename(file, ".md")] ?? {}; const [start, end] = String(e.period).split(" - "); const bullets = body.split("\n").filter((l) => l.startsWith("- ")).map((l) => l.slice(2).trim()); const extra = body.split("\n").filter((l) => l.trim() && !l.startsWith("- ") && !/^Key contributions:?$/.test(l.trim())).join("\n"); add( { type: "experience", - title: `${e.title} at ${e.company}`, + title: `${(answers.role as string | undefined) ?? e.title} at ${e.company}`, publish: true, company: e.company, role: e.title, @@ -278,10 +280,15 @@ export function build(legacyDir: string): BuildResult { focus: e.focus, bullets, created: today, + ...answers, }, extra, ); } + { + const { body, ...rest } = seed.resumePage; + add({ type: "page", created: today, ...rest }, body); + } add({ type: "skills", title: "Skills", publish: true, ...seed.skills }); add({ type: "education", title: "Education", publish: true, ...seed.education }); @@ -335,7 +342,8 @@ export function build(legacyDir: string): BuildResult { summary: p.summary, icon: p.icon, order: i + 1, - slots: store.slots[p.slotKey], + // Greg (2026-10-11): orders stay closed until he opens them, so no slot shows as open. + slots: { ...store.slots[p.slotKey], open: 0 }, images: p.images.map((im: any) => ({ file: attach(im.src, "Store"), alt: im.alt, label: im.label })), specs: p.specs, priceTiers: p.pricing, diff --git a/tools/migrate/src/seed.ts b/tools/migrate/src/seed.ts index 334da5a..c039031 100644 --- a/tools/migrate/src/seed.ts +++ b/tools/migrate/src/seed.ts @@ -16,6 +16,10 @@ export const site = { tagline: "Developer. Maker. Storyteller.", lockup: "Web Development · a Patiun LLC company", location: "Philadelphia, PA", + /** legacy contact pages; Greg confirmed it for the resume PDF (Resume Interview, 2026-10-11). */ + email: "greg@gregkilmer.com", + /** Resume Interview: open to offers but not looking, so the page says this. */ + availability: "Open to freelance web work", socials: [ { label: "GitHub", handle: "Patiun", url: "https://github.com/patiun" }, { label: "LinkedIn", handle: "Gregory Kilmer", url: "https://www.linkedin.com/in/gregory-kilmer-36646bb5/" }, @@ -222,11 +226,15 @@ export const favoriteBoardGames = ["Spirit Island", "Wyrmspan"]; // ---------------------------------------------------------------- Resume -/** legacy/src/components/professional/SkillGrid.astro */ +/** + * legacy/src/components/professional/SkillGrid.astro, with the stack Greg added + * in the Resume Interview (2026-10-11). Core items are the ones he named for + * Implico's main stack. + */ export const skills = { core: [ - "Cloud Architecture & Development", - "Mobile Application Development", + "Software Architecture & System Design", + "Cloud & Mobile Application Development", "SAP Business Technology Platform (BTP)", "Team Leadership & Technical Mentorship", "Cross-functional Communication", @@ -237,14 +245,89 @@ export const skills = { "Advanced automation and workflow engineering", ], stack: [ - { label: "Languages", items: ["JavaScript", "TypeScript", "Java", "Python", "C#", "C", "SQL", "HTML", "CSS"] }, - { label: "Frameworks", items: ["React", "Vue", "Astro", "Flask"] }, - { label: "Cloud Platforms", items: ["SAP BTP", "Google Cloud Platform", "AWS"] }, - { label: "Tools", items: ["PostgreSQL", "VSCode", "Unity", "XCode", "Android Studio", "Git", "Gitea", "CI/CD pipelines"] }, + { + label: "Languages", + items: ["JavaScript", "TypeScript", "Python", "Java", "C#", "C", "SQL", "Bash", "Awk", "HTML", "CSS"], + core: ["JavaScript", "Python"], + }, + { label: "Frameworks", items: ["React", "React Native", "Node.js", "SAP CAP", "Vue", "Astro", "Flask"], core: ["React", "React Native", "SAP CAP"] }, + { + label: "Cloud Platforms", + items: ["SAP BTP (Cloud Foundry)", "SAP Integration Suite", "SAP Build Apps", "SAP Build Work Zone", "Google Cloud Platform", "AWS"], + core: ["SAP BTP (Cloud Foundry)", "SAP Integration Suite"], + }, + { label: "Mobile", items: ["iOS", "Android", "Xcode", "Android Studio"] }, + { label: "Data", items: ["PostgreSQL", "NumPy", "Pandas"] }, + { label: "Tools", items: ["Docker", "Git", "Gitea", "CI/CD pipelines", "VS Code", "Unity"] }, { label: "AI/ML", items: ["Machine learning frameworks", "LLM integration", "Claude Code", "Hermes", "OpenClaw"] }, ], }; +/** + * Greg's Resume Interview answers (2026-10-11), written up as the experience + * notes. Keyed by the legacy file in src/content/experience; these fields + * replace the legacy ones. Numbers are his, as he gave them. + */ +export const experience: Record> = { + implico: { + role: "Team Lead, BTP Cloud & Mobile Apps and US Managed Services", + previousRoles: ["BTP Cloud and Mobile App Lead", "Lead Developer", "Software Developer"], + location: "Remote", + focus: "Leading the team behind Carrier Connect, an SAP logistics platform for third-party carriers.", + bullets: [ + "Architected Carrier Connect, a suite of integration and management tools built for SDM for SAP that lets third-party carriers connect to a central purchase order system, track loading and delivery events, and streamline their documents.", + "Designed several integration paths into one delivery interface (REST APIs, Excel and CSV file transfer, and the companion Driver App), and personally built the delivery interface, the file transfer interfaces and the inventory polling and delivery system.", + "Carrier Connect runs at 99.999% uptime and handles 2,000+ orders a day for 500+ active users across 50+ companies, saving about 72 hours per delivery.", + "Lead a development team of 4, plus 6 extended members on the US Managed Services team.", + "Set the team's coding standards on Google's published best practices, along with code review, pull request, testing and code coverage standards.", + "Brought React and AI-assisted development to the team, and was the main author of its responsible AI guidelines: factual accuracy, reputation and transparency, human oversight, data security, and consequences for misuse.", + ], + tech: ["React Native", "React", "Python", "SAP CAP", "SAP BTP (Cloud Foundry)", "SAP Build Work Zone", "SAP Build Apps", "SAP Integration Suite", "iOS", "Android"], + }, + drexel: { + role: "Assistant System Administrator, then Computer Lab Help Desk", + end: "2018-06", + earlier: true, + focus: "Maintained 4 Linux servers for the College of Computing and Informatics and automated every workstation in 3 labs and the laptop carts with Ansible.", + bullets: [ + "Maintained the college's Linux server network (4 servers serving the whole College of Computing and Informatics) with Ansible, Bash, Python and MySQL.", + "Automated deployment and maintenance of every workstation in the 3 computer labs and the mobile laptop carts with Ansible.", + "Protected college-wide data with Ceph storage backups.", + "Stayed on at the Computer Lab help desk through June 2018, supporting faculty, staff and students.", + ], + }, + comcast_1: { + role: "Junior Developer (co-op), then Senior Intern", + earlier: true, + focus: "Led a team of 10 co-ops building smart remote, call center and inventory tools, and built an NLP sentiment pipeline for leadership.", + bullets: [ + "Hired back after the co-op as Senior Intern to run a team of 10 co-ops building smart remote, call center and inventory tools.", + "Built an NLP system that gathered remote speech input and reviews so leadership teams could gauge customer sentiment (Java, Groovy, Grails, Elasticsearch, MySQL).", + "Built call center and inventory features on the internal logistics platform for techs and warehouse teams (PHP, MySQL).", + "Worked in weeklong Agile sprints.", + ], + }, +}; + +/** The /resume page's own text, from the Resume Interview (2026-10-11). */ +export const resumePage = { + title: "Resume", + page: "/resume", + publish: true, + lede: "Team Lead, SAP BTP Cloud & Mobile Apps", + stats: [ + { value: "99.999%", label: "Uptime", detail: "Carrier Connect" }, + { value: "2,000+", label: "Orders a day", detail: "Through Carrier Connect" }, + { value: "500+", label: "Active users", detail: "Across 50+ companies" }, + { value: "10", label: "People led", detail: "Development and Managed Services" }, + ], + projects: [ + { title: "Cosmos homelab", detail: "20+ Docker containers working with 17 AI agents on a private network." }, + { title: "GregKilmer.com", detail: "This site, run from an Obsidian vault through a rule-checked content service and MCP server." }, + ], + body: `I have a strong eye for software design and architecture, and a broad range of experience across cloud, app and game development. I stay current with the cutting edge of AI and use AI tools to speed up my workflow, but I'm skilled enough to do the work without them.`, +}; + /** legacy/src/components/professional/EducationHistory.astro and Certifications.astro */ export const education = { degrees: [ @@ -255,14 +338,24 @@ export const education = { detail: "Foundational coursework in software engineering, algorithms, and systems. Focuses in AI and human-computer interaction.", }, ], - certifications: [{ name: "Planet 9 Development", issuer: "Neptune", year: "2019", status: "Active" as const }], + // Resume Interview: Neptune Software Planet 9, earned 2019. + certifications: [{ name: "Planet 9 Development", issuer: "Neptune Software", year: "2019", status: "Active" as const }], }; /** Employer and facts for the legacy projects; facts from the Implico and Cosmos entries. */ export const caseStudies: Record = { - "carrier-connect": { employer: "Implico Inc.", facts: [{ label: "Active users", value: "1,000+" }, { label: "Companies", value: "100" }] }, + // Resume Interview (2026-10-11): Greg's corrected numbers. + "carrier-connect": { + employer: "Implico Inc.", + facts: [ + { label: "Active users", value: "500+" }, + { label: "Companies", value: "50+" }, + { label: "Orders a day", value: "2,000+" }, + { label: "Uptime", value: "99.999%" }, + ], + }, "driver-app": { employer: "Implico Inc." }, - "cosmos-homelab": { facts: [{ label: "AI agents", value: "17" }] }, + "cosmos-homelab": { facts: [{ label: "Docker containers", value: "20+" }, { label: "AI agents", value: "17" }] }, "placeholder-project": {}, }; diff --git a/tools/migrate/test/migrate.test.ts b/tools/migrate/test/migrate.test.ts index 25d7588..d945d12 100644 --- a/tools/migrate/test/migrate.test.ts +++ b/tools/migrate/test/migrate.test.ts @@ -41,12 +41,19 @@ describe("migration", () => { expect(find("library-item", "Cosmere")?.data).toMatchObject({ creator: "Brandon Sanderson", medium: "book" }); }); - it("keeps the store's real prices and slots", () => { + it("keeps the store's real prices, with no slot open until Greg opens orders", () => { const proxies = find("product-line", "Proxy MTG Cards")!.data as any; - expect(proxies.slots).toEqual({ open: 1, total: 3 }); + expect(proxies.slots).toEqual({ open: 0, total: 3 }); expect(proxies.priceTiers).toContainEqual({ band: "100 card deck", price: "$40.00", note: "per set of cards" }); }); + it("takes the resume from Greg's interview answers", () => { + const implico = find("experience", "Team Lead, BTP Cloud & Mobile Apps and US Managed Services at Implico Inc.")!.data as any; + expect(implico.bullets.join(" ")).toContain("500+ active users across 50+ companies"); + expect(result.notes.filter((n) => n.data.type === "experience" && (n.data as any).earlier)).toHaveLength(2); + expect(find("page", "Resume")?.data).toMatchObject({ page: "/resume", stats: expect.any(Array), projects: expect.any(Array) }); + }); + it("logs status for everything in progress", () => { const kinds = result.notes.filter((n) => n.data.type === "status").map((n) => `${n.data.kind}: ${n.data.title}`); expect(kinds).toContain("Playing: Slay the Spire 2"); diff --git a/vault-kit/24.01_PersonalWebsite/Site/_templates/Experience.md b/vault-kit/24.01_PersonalWebsite/Site/_templates/Experience.md index 11a80d2..790f180 100644 --- a/vault-kit/24.01_PersonalWebsite/Site/_templates/Experience.md +++ b/vault-kit/24.01_PersonalWebsite/Site/_templates/Experience.md @@ -12,6 +12,7 @@ location: # optional focus: # optional. One line under the role. bullets: [] tech: [] # optional +earlier: false # optional. Shows in the short Earlier list: title, company, dates and the focus line only. ---