Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ When a press-and-hold on the coin lands it heads up (the profile picture), a blu
It shows through the end of `until` (Eastern time), then switches itself off without a redeploy. `npm run build:coin-bubble` (before tests and dev) writes `data/coinBubble.generated.mjs`, with the QR already turned into an SVG path (`utils/coinQr.mjs`, using `uqr`), and fails the build on a bad setting (`utils/coinBubble.mjs`).

## About card (home page)
`components/AboutCard.vue` holds the Skills, Hobbies and XP tabs. XP is a timeline by year: a slider from 2014 to now shows each year's education, work, volunteer, career-break and achievement entries, with a category icon for each. Swipe left or right across the card to move through the years (a swipe left goes to later years). The mouse wheel works too while the pointer is over the card, or while a keyboard or controller has focus in it: sideways or down goes to later years, and at the first or last year the page scrolls on. While the card is in use the social links below pause (they move as the card changes size): on phones until the card is scrolled away or you tap elsewhere (a tap on a paused link just unpauses it), with a mouse for a second after the press. The data lives in the component's `education`, `experience` and `achievements` arrays. Hold the card (or alt-click on desktop) for plain English. In plain English, visitors whose browser prefers another language get a small link to read it in Google Translate (`utils/translate.js`); English readers don't see it. A link to `/#xp` (the Bio page has one) opens the card on XP (`utils/aboutCard.js`).
`components/AboutCard.vue` holds the Skills, Hobbies and XP tabs. XP is a timeline by year: a slider from 2014 to now shows each year's education, work, volunteer, career-break and achievement entries, with a category icon for each. Swipe left or right across the card to move through the years (a swipe left goes to later years). The mouse wheel works too while the pointer is over the card, or while a keyboard or controller has focus in it: sideways or down goes to later years, and at the first or last year the page scrolls on. While the card is in use the social links below pause (they move as the card changes size): on phones until the card is scrolled away or you tap elsewhere (a tap on a paused link just unpauses it), with a mouse for a second after the press. The data lives in `data/xp.mjs` (`education`, `experience` and `achievements`). Hold the card (or alt-click on desktop) for plain English. In plain English, visitors whose browser prefers another language get a small link to read it in Google Translate (`utils/translate.js`); English readers don't see it. A link to `/#xp` (the Bio page has one) opens the card on XP (`utils/aboutCard.js`).

Kudos: in plain English, each XP entry has a 👏 button with a live count shared by every visitor (`composables/useKudos.js`, refreshed every 15 seconds while the list is showing). The `/api/kudos` Netlify function (`netlify/functions/kudos.mjs`) keeps the counts as one JSON document in a Netlify Blobs store named `kudos`, apart from the site's data, keyed by each entry's id (`xpId` in `data/xp.mjs`, made from the fields that name the entry, so rewording a line keeps its kudos). It counts only known entries, once per visitor per entry per day (by a hash of their IP address, which isn't stored), and only from the site's own pages. Nothing to set up: Netlify provides the store. Where the function isn't running (a local preview), the buttons don't show.

The social links under the card are in `components/Socials.vue`. Tapping Instagram opens a small menu with both accounts: hansel.rocks (main) and hansel.codes (code and software engineering only). A pill gets a menu from its `choices` list (`components/SocialFlipLink.vue`). The addresses are in `utils/outbound.js`. On phones and tablets each link opens a new tab through `/out/<id>` (`pages/out/[id].vue`), which goes on to the link; when the phone hands the link to an app instead, that tab closes itself once you come back, so no blank tab is left behind.

Expand Down
186 changes: 53 additions & 133 deletions components/AboutCard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,20 @@
<span class="xp-entry__count" aria-hidden="true">{{ i + 1 }}/{{ yearEntries.length }}</span>
<span class="visually-hidden">Entry {{ i + 1 }} of {{ yearEntries.length }}, {{ entry.category }}: </span>
<strong>{{ entry.label }}</strong> · {{ entry.text }}
<!-- Live kudos (composables/useKudos.js): the count is in the label, not read out on every refresh -->
<button
v-if="kudos.available.value"
type="button"
:class="['kudos', { 'kudos--given': kudos.given.value.has(entry.id) }]"
:aria-label="kudos.given.value.has(entry.id) ? `Kudos given: ${entry.name}, ${kudos.counts.value[entry.id] ?? 0} in all` : `Give kudos: ${entry.name}, ${kudos.counts.value[entry.id] ?? 0} so far`"
:aria-disabled="kudos.given.value.has(entry.id) ? 'true' : undefined"
@click.stop="kudos.give(entry.id)"
@pointerdown.stop
@pointerup.stop
@keydown.stop
>
<span aria-hidden="true">👏 {{ kudos.counts.value[entry.id] ?? 0 }}</span>
</button>
</li>
</ul>
<p v-else key="edu-json" class="json json--edu json--xp soft-pulse">{{ yearJson }}</p>
Expand Down Expand Up @@ -149,6 +163,8 @@ import { languageName, translateTarget, translateUrl } from '~/utils/translate.j
import { swipeIntent, swipeYears, WHEEL_STEP_PX, wheelDx, wheelDy } from '~/utils/swipe.js'
import { isActionTarget } from '~/utils/actionTarget.js'
import { opensXp } from '~/utils/aboutCard.js'
import { achievements, education, experience, xpId } from '~/data/xp.mjs'
import { useKudos } from '~/composables/useKudos.js'

// One card under the contact button: the token's latest fact, or the "I am a"
// (skills), hobbies and education JSON, switched with the toggle on top.
Expand Down Expand Up @@ -195,139 +211,6 @@ const hobbies = [
const hobbyWords = { Maker: 'makerspace things (3D printing, crafting, stained glass)', 'Stained Glass': null }
const hobbyPhrases = hobbies.map(hobby => (hobby in hobbyWords ? hobbyWords[hobby] : hobby.toLowerCase())).filter(Boolean)

// XP: certifications and education, then work history (newest first). `readable` and
// `highlight` are the plain-English text; the JSON view uses the fields only.
const education = [
// The AMGA SPI certification is the course plus a separate exam: only the course is done
{
category: 'Education',
program: 'AMGA Single Pitch Instructor (SPI) Course',
passed: 'May 2026',
status: 'Course passed; SPI exam not yet taken (certification needs both)',
school: 'Pisgah Climbing School',
location: 'Brevard, NC',
date: 'Passed May 2026',
readable: 'AMGA Single Pitch Instructor (SPI) Course with Pisgah Climbing School in Brevard, NC. The SPI certification also needs the separate SPI exam, which I haven\'t taken yet.'
},
{
category: 'Certification',
certification: 'Data Science and Machine Learning: Making Data-Driven Decisions',
passed: 'September 2024',
school: 'MIT Institute for Data, Systems, and Society (IDSS)',
location: 'Online',
date: 'Passed September 2024',
readable: 'Certification: Data Science and Machine Learning: Making Data-Driven Decisions from MIT IDSS.'
},
{
category: 'Education',
graduated: 2025,
program: 'YTT200',
school: 'Chakra Flow University',
location: 'Charlotte, NC',
date: '2025',
readable: '200-hour Yoga Teacher Training Graduate from Chakra Flow University at the Whitewater Center in Charlotte, NC.'
},
{
category: 'Education',
started: 2016,
graduated: 2019,
program: 'Computer Science, BS / Communication Studies Minor',
school: 'UNC Charlotte',
location: 'Charlotte, NC',
date: '2016 – 2019',
readable: 'Bachelor of Science in Computer Science with a minor in Communication Studies from UNC Charlotte in Charlotte, NC.'
}
]

const experience = [
{ category: 'Work', role: 'Senior Software Engineer', company: 'Wells Fargo', start: 'Sep 2025', end: 'Present', location: 'Charlotte, NC', highlight: 'Engineer, develop and maintain GitHub Actions, ARC, Workflows and Kubernetes enterprise-wide platform solutions; lead projects and act as an escalation point for less experienced staff' },
{ category: 'Work', role: 'Software Engineer 4', company: 'Apex Systems (Wells Fargo)', start: 'Jun 2025', end: 'Sep 2025', location: 'Hybrid', highlight: 'Engineered language-specific GitHub Actions support across Python, Ruby, C#, Node.js and Java; built GitHub-hosted runners on Azure VNet and customized self-hosted runners for TKGI and OpenShift' },
{ category: 'Volunteer', role: 'Voluntary work', company: 'Project H.E.R.O., Jubilee House Community', start: 'Jan 2025', end: 'May 2025', location: 'Remote', highlight: 'Founding project manager and lead maintainer of an open-source naloxone vending machine project' },
{ category: 'Work', role: 'Senior Software Engineer', company: 'Brooksource (Ally Financial)', start: 'Jun 2024', end: 'Dec 2024', location: 'Charlotte, NC, hybrid', highlight: 'GenAI and RAG for audit, compliance and risk; migrated a React app to Vite (95% faster build and runtime); AWS and Terraform infrastructure; Technology Achievement Award for a 25% cut in audit processing time' },
{ category: 'Career break', role: 'Career break', start: 'Apr 2024', end: 'Jun 2024', highlight: 'Position eliminated' },
{ category: 'Work', role: 'Senior Software Developer', company: 'TIAA', start: 'Mar 2022', end: 'Apr 2024', location: 'United States', highlight: 'Founded the Developer Experience Guild; platform SME across the enterprise; L3 platform support and continuous improvement on OpenShift, Kong and CI/CD' },
{ category: 'Work', role: 'Developer II', company: 'TIAA', start: 'Jan 2021', end: 'Mar 2022', location: 'Charlotte, NC', highlight: 'Full-stack development and solution architecture for imaging, data distribution and workflow systems' },
{ category: 'Work', role: 'Developer', company: 'TIAA', start: 'Jan 2020', end: 'Jan 2021', location: 'Charlotte, NC', highlight: 'Full-stack development on the TIAA Robotics Platform and Imaging Distribution; reverse-engineered and refactored legacy monoliths into microservices with Spring Boot and OpenShift' },
{ category: 'Work', role: 'Full Stack Developer, Robotics Platform', company: 'TIAA (Brooksource)', start: 'Jul 2019', end: 'Jan 2020', location: 'Charlotte, NC', highlight: 'Automation Anywhere RPA bots and process intelligence systems; LSTM text classification model; cognitive services platform for text, voice and object detection' },
{ category: 'Work', role: 'Computer Scientist (Research and Development)', company: 'National Gypsum Company', start: 'May 2019', end: 'Jul 2019', location: 'Charlotte, NC', highlight: 'Azure Machine Learning deployment for material science; migrated ensemble ML from Python to C#/.NET; UX research and an analytics dashboard' },
{ category: 'Work', role: 'Computer Science Intern (Research and Development)', company: 'National Gypsum Company', start: 'Jan 2019', end: 'May 2019', location: 'Charlotte Metro', highlight: 'Partnered with Microsoft engineers on Azure AI data modeling and data science pipelines for real-time and batch processing; prototyped Azure ML environments; built a Power BI visualization and a data analytics dashboard proof of concept' },
{ category: 'Work', role: 'College of Computing and Informatics Tutor', company: 'UNC Charlotte', start: 'Jan 2017', end: 'May 2019', location: 'Charlotte, NC', highlight: 'One-on-one tutoring for 10+ undergrad and grad CS students in Java, C, data structures, machine learning, AWS and networking; won HackNC 2018 (Major League Hacking\'s 2019 season); gave over a dozen workshops at hackathons and campus events' },
{ category: 'Volunteer', role: 'Guest speaker, GenCyber camp', company: 'Winthrop University, Richard W. Riley College of Education', start: '2017', end: '2017', location: 'Rock Hill, SC', highlight: 'Demonstrated open wireless network insecurity and how to ethically hack a network for teachers and students, as a student and computer science peer tutor at UNC Charlotte' },
{ category: 'Work', role: 'Associate', company: 'Carowinds', start: 'Apr 2016', end: 'Oct 2016', location: 'Charlotte Metro', highlight: 'Cashier supervisor responsible for handling money; supervised a team and improved customer satisfaction and accountability' },
{ category: 'Work', role: 'Computer Repair Technician / Network Designer (self-employed)', start: 'Jul 2014', end: 'Jul 2017', highlight: 'Remote support by phone, email and TeamViewer; on-site hardware upgrades and FRU repairs; network, server and security design for small businesses' }
]

// Achievements: milestones on the XP timeline (the medal). `date` is when it happened; MLH
// hackathons also carry their Major League Hacking season, which runs from late summer to early
// summer and is named for the year it ends (fall 2018 events are the 2019 season)
const achievements = [
{
category: 'Achievement',
event: 'MLH: hackathonCLT MMXIX',
season: 'MLH 2019 season',
place: 'Submitted',
date: '2019',
location: 'Charlotte, NC',
readable: 'Submitted a project to hackathonCLT MMXIX (MLH 2019 season) in Charlotte, NC (March 2019).'
},
{
category: 'Achievement',
event: 'MLH: HackDuke 2018: Code for Good',
season: 'MLH 2019 season',
place: 'Submitted',
date: '2018',
location: 'Durham, NC',
readable: 'Submitted a project to HackDuke 2018: Code for Good (MLH 2019 season) in Durham, NC (October 2018).'
},
{
category: 'Achievement',
event: 'MLH: HackNC 2018',
season: 'MLH 2019 season',
place: 'Winner',
project: 'Human Trafficking Textual and Emoji Analysis',
description: 'Feature generation and emoji text analysis, for machine learning, to help prevent human trafficking.',
date: '2018',
location: 'Chapel Hill, NC',
readable: 'Winner at HackNC 2018 (MLH 2019 season) in Chapel Hill, NC (October 2018) with Human Trafficking Textual and Emoji Analysis: feature generation and emoji text analysis to help prevent human trafficking.'
},
{
category: 'Achievement',
event: 'MLH: HackDuke 2017: Code for Good',
season: 'MLH 2018 season',
place: 'Submitted',
date: '2017',
location: 'Durham, NC',
readable: 'Submitted a project to HackDuke 2017: Code for Good (MLH 2018 season) in Durham, NC (October 2017).'
},
{
category: 'Achievement',
event: 'MLH: RamHacks 2017',
season: 'MLH 2018 season',
place: 'Submitted',
project: 'ramhacksfall2017',
date: '2017',
location: 'Richmond, VA',
readable: 'Submitted ramhacksfall2017 to RamHacks 2017 (MLH 2018 season) in Richmond, VA (September 2017).'
},
{
category: 'Achievement',
event: 'Collegiate Cyber Defense Competition (CCDC), Southeast Regional Finals',
place: 'Fourth at Kennesaw State University (UNC Charlotte team)',
date: '2018',
location: 'Kennesaw, GA',
readable: 'Fourth place: UNC Charlotte team at the Southeast Regional Finals of the Collegiate Cyber Defense Competition (CCDC) at Kennesaw State University (2018).'
},
{
category: 'Achievement',
event: 'Around the Crown 10K',
distance: '10K',
first: true,
date: 'Sep 2026',
location: 'Charlotte, NC',
readable: 'First 10K: Around the Crown 10K in Charlotte, NC.'
}
]

// Valid JSON on one line, like the other About views, built from the fields so quoting is right
const jsonValue = value => {
if (Array.isArray(value)) return `[ ${value.map(jsonValue).join(', ')} ]`
Expand Down Expand Up @@ -357,6 +240,8 @@ const timeline = [
...education.map(entry => {
const to = yearOf(entry.graduated ?? entry.passed)
return {
id: xpId(entry),
name: entry.program === 'YTT200' ? 'Yoga Teacher Training' : entry.program ?? entry.certification,
from: entry.started ?? to,
to,
label: entry.date,
Expand All @@ -366,6 +251,8 @@ const timeline = [
}
}),
...experience.map(job => ({
id: xpId(job),
name: job.company ? `${job.role} at ${job.company}` : job.role,
from: yearOf(job.start),
to: job.end === 'Present' ? CURRENT_YEAR : yearOf(job.end),
label: job.start === job.end ? job.start : `${job.start} – ${job.end}`,
Expand All @@ -374,6 +261,8 @@ const timeline = [
json: `{ "experience" : ${jsonValue(job.highlight ? job : { ...job, highlight: undefined })} }`
})),
...achievements.map(item => ({
id: xpId(item),
name: item.event,
from: yearOf(item.date),
to: yearOf(item.date),
label: item.date,
Expand Down Expand Up @@ -491,6 +380,9 @@ const HOLD_MS = 900
const HOLD_SLOP_PX = 10 // moving further than this (e.g. scrolling) cancels the hold
const TAP_MAX_MS = 300 // a press longer than this that lets go early is an abandoned hold, not a tap
const readable = ref(false)
// Kudos on each plain-English XP entry, live while that list is on screen
const kudos = useKudos()
kudos.watchActive(() => view.value === 'edu' && readable.value)
const charging = ref(false)
const transitionName = ref('swap')
const cardEl = ref(null)
Expand Down Expand Up @@ -1030,6 +922,34 @@ cite {
line-height: 1.55;
}

.kudos {
display: inline-flex;
align-items: center;
min-height: 24px;
min-width: 24px;
margin-left: 0.4rem;
padding: 0 0.5rem;
border: 1px solid #475569;
border-radius: 999px;
background: transparent;
color: #e2e8f0;
font-size: 0.75rem;
line-height: 1.4;
white-space: nowrap;
cursor: pointer;
touch-action: manipulation;
}

.kudos:hover,
.kudos:focus-visible {
border-color: #94a3b8;
}

.kudos--given {
border-color: #22c55e;
cursor: default;
}

.edu-list li + li {
margin-top: 0.45rem;
}
Expand Down
Loading
Loading