From f3d9676857a80b2f0a22cfb59355e67ceb5fe2dc Mon Sep 17 00:00:00 2001 From: Michael Stanclift Date: Tue, 4 Aug 2026 14:18:48 -0500 Subject: [PATCH] Add CV section to Whois page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Résumé section between the profile card and the Elsewhere directory, covering experience and certifications. Experience is a timeline with dates on the left rail. Roles linked by continuesFrom collapse into one entry, so the VMware to Broadcom acquisition reads as a single unbroken stint rather than two jobs. Companies acquired since I left carry a note. Certifications are pulled from the Credly API at build time and split into active and expired disclosures, filtered to VMware-lineage badges earned from 2015 on. A committed snapshot in src/data/credly-badges.json is the fallback when Credly is unreachable, so an outage there cannot fail the build; pnpm sync:credly refreshes it. Credly moves out of the Elsewhere directory since each badge now links to itself. A weekly cron rebuilds the site so certification expiry stays current without a commit. Co-Authored-By: Claude Opus 5 --- .github/workflows/deploy.yml | 4 + package.json | 3 +- scripts/sync-credly.mjs | 24 ++ src/components/CertificationCard.astro | 48 ++++ src/components/WhoisDirectory.astro | 225 +++++++++++++++++ src/content/pages/whois.md | 2 +- src/data/credly-badges.json | 118 +++++++++ src/data/whois.ts | 86 ++++++- src/lib/credly.ts | 171 +++++++++++++ src/styles/global.css | 335 ++++++++++++++++++++++++- 10 files changed, 1000 insertions(+), 16 deletions(-) create mode 100644 scripts/sync-credly.mjs create mode 100644 src/components/CertificationCard.astro create mode 100644 src/data/credly-badges.json create mode 100644 src/lib/credly.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4e5c50b..2ace1e5 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,6 +5,10 @@ on: branches: - main pull_request: + # Weekly rebuild so build-time data (Credly certifications) stays current + # without a commit. Mondays at 12:00 UTC. + schedule: + - cron: "0 12 * * 1" workflow_dispatch: permissions: diff --git a/package.json b/package.json index 9fd4f51..d2bcbaa 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "build": "astro check && astro build && pagefind --site dist", "preview": "astro preview", "deploy": "pnpm build && wrangler deploy", - "import:ghost": "node scripts/import-ghost.mjs" + "import:ghost": "node scripts/import-ghost.mjs", + "sync:credly": "node scripts/sync-credly.mjs" }, "dependencies": { "@astrojs/rss": "^4.0.19", diff --git a/scripts/sync-credly.mjs b/scripts/sync-credly.mjs new file mode 100644 index 0000000..f2aad09 --- /dev/null +++ b/scripts/sync-credly.mjs @@ -0,0 +1,24 @@ +import { writeFileSync } from "node:fs"; + +import { fetchCredlyBadges } from "../src/lib/credly.ts"; + +const handle = process.argv[2] ?? "vmstan"; +const target = "src/data/credly-badges.json"; + +try { + const badges = await fetchCredlyBadges(handle); + + writeFileSync( + target, + `${JSON.stringify( + { handle, fetchedAt: new Date().toISOString(), badges }, + null, + 2, + )}\n`, + ); + + console.log(`Wrote ${badges.length} badges for ${handle} to ${target}`); +} catch (error) { + console.error(`Unable to sync Credly badges: ${error.message}`); + process.exitCode = 1; +} diff --git a/src/components/CertificationCard.astro b/src/components/CertificationCard.astro new file mode 100644 index 0000000..af05d02 --- /dev/null +++ b/src/components/CertificationCard.astro @@ -0,0 +1,48 @@ +--- +import type { Certification } from "../lib/credly"; + +interface Props { + certification: Certification; + expired?: boolean; +} + +const { certification, expired = false } = Astro.props; + +const dayFormatter = new Intl.DateTimeFormat("en-US", { + month: "long", + year: "numeric", + timeZone: "UTC", +}); + +function formatBadgeDate(value: string) { + return dayFormatter.format(new Date(`${value}T00:00:00Z`)); +} + +const meta = [ + certification.abbreviation, + `Earned ${formatBadgeDate(certification.issuedOn)}`, + certification.expiresOn && + `${expired ? "Expired" : "Valid through"} ${formatBadgeDate(certification.expiresOn)}`, +] + .filter(Boolean) + .join(" · "); +--- + + + + + {certification.name} + { + certification.track && ( + {certification.track} + ) + } + {meta} + + + diff --git a/src/components/WhoisDirectory.astro b/src/components/WhoisDirectory.astro index e4ef714..897120e 100644 --- a/src/components/WhoisDirectory.astro +++ b/src/components/WhoisDirectory.astro @@ -1,10 +1,15 @@ --- import { + whoisCredlyHandle, whoisGroups, whoisProfile, + whoisRoles, + type WhoisRole, type WhoisService, type WhoisTier, } from "../data/whois"; +import { earliestCertificationYear, loadCertifications } from "../lib/credly"; +import CertificationCard from "./CertificationCard.astro"; const serviceCount = whoisGroups.reduce( (count, group) => @@ -50,6 +55,72 @@ function getAge(birthDate: string, today = new Date()) { } const currentAge = getAge(whoisProfile.birthDate); + +// Short months keep the timeline rail narrow enough to sit beside the roles. +const monthFormatter = new Intl.DateTimeFormat("en-US", { + month: "short", + year: "numeric", + timeZone: "UTC", +}); + +function formatMonth(value: string) { + return monthFormatter.format(new Date(`${value}-01T00:00:00Z`)); +} + +function tenureParts(span: { start: string; end?: string }) { + return { + start: formatMonth(span.start), + end: span.end ? formatMonth(span.end) : "Present", + }; +} + +/** + * Collapses roles linked by `continuesFrom` into a single run, so an unbroken + * stint that changed title or owner reads as one entry rather than two jobs. + * Roles stay newest-first, within runs and across them. + */ +function employmentRuns(roles: WhoisRole[]) { + const runs: WhoisRole[][] = []; + + for (const role of roles) { + const current = runs.at(-1); + + if (current?.at(-1)?.continuesFrom) { + current.push(role); + } else { + runs.push([role]); + } + } + + return runs.map((group) => { + const newest = group[0]!; + const oldest = group.at(-1)!; + + return { + roles: group, + company: newest.company, + formerly: [ + ...new Set( + group + .slice(1) + .map((role) => role.company) + .filter((company) => company !== newest.company), + ), + ], + start: oldest.start, + end: newest.end, + }; + }); +} + +const runs = employmentRuns(whoisRoles); + +const careerStartYear = whoisRoles.reduce( + (earliest, role) => Math.min(earliest, Number(role.start.slice(0, 4))), + Number.POSITIVE_INFINITY, +); + +const { active, expired } = await loadCertifications(whoisCredlyHandle); ---
@@ -109,6 +180,160 @@ const currentAge = getAge(whoisProfile.birthDate); } +
+
+
+

Résumé

+

Career

+
+

Since {careerStartYear}

+
+ +
+
+
+

Experience

+

Where I've worked, most recent first.

+
+
    + { + runs.map((run) => { + const tenure = tenureParts(run); + const lead = run.roles[0]!; + const grouped = run.roles.length > 1; + + return ( +
  1. + + {tenure.start}{" "} + + {" "} + {tenure.end} + + + + {run.company} + {grouped + ? run.formerly.length > 0 && ( + +