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
5 changes: 4 additions & 1 deletion .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ on:

jobs:
quality:
name: Lint, Format, Test
name: Lint, Format, Test, Build
runs-on: ubuntu-latest

steps:
Expand All @@ -34,3 +34,6 @@ jobs:

- name: Test
run: npm run test

- name: Build
run: npm run build
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
node_modules
_site
.codex
18 changes: 18 additions & 0 deletions _includes/components/date-range.njk
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{% macro renderDateRange(start, end, includeDuration=false) %}
<aside class="date-range">
<time datetime="{{ start }}">{{ start | monthYear }}</time>
&ndash;
<time{% if end %} datetime="{{ end }}"{% endif %}>{{ end | monthYearOrPresent }}</time>
{% if includeDuration %}
{% if end %}
<span class="duration">({{ start | durationText(end) }})</span>
{% else %}
<span class="duration duration--ongoing" data-start="{{ start }}" hidden></span>
{% endif %}
{% endif %}
</aside>
{% endmacro %}

{% macro renderDateRangeWithDuration(start, end) %}
{{ renderDateRange(start, end, true) }}
{% endmacro %}
1 change: 1 addition & 0 deletions _includes/layouts/base.njk
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
{{ content | safe }}
</article>
</section>
<script type="module" src="/assets/scripts/main.js"></script>
<script async defer src="https://www.googletagmanager.com/gtag/js?id={{ site.analytics.gtag_id }}"></script>
<script>
window.dataLayer = window.dataLayer || [];
Expand Down
4 changes: 3 additions & 1 deletion _includes/sections/education.njk
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
{% from "components/date-range.njk" import renderDateRange %}

<section class="resume-section education">
<h2>Education</h2>
<ul>
Expand All @@ -6,7 +8,7 @@
<input type="checkbox" id="edu_{{ entry.id }}" />
<label for="edu_{{ entry.id }}">
{{ entry.degree }}, <a class="company-name" href="{{ entry.institution.url }}" rel="external" target="_blank">{{ entry.institution.name }}</a>
<aside class="date-range"><time datetime="{{ entry.start }}">{{ entry.start | monthYear }}</time> &ndash; <time datetime="{{ entry.end }}">{{ entry.end | monthYear }}</time></aside>
{{ renderDateRange(entry.start, entry.end) }}
</label>
<ul class="expandable-content">
{% for detail in entry.details %}
Expand Down
6 changes: 4 additions & 2 deletions _includes/sections/experience.njk
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
{% from "components/date-range.njk" import renderDateRangeWithDuration %}

<section class="resume-section experience">
<h2>Experience</h2>
<ul>
Expand All @@ -20,14 +22,14 @@
{% if entry.company.suffix_html %}
{{ " " }}{{ entry.company.suffix_html | safe }}
{% endif %}
<aside class="date-range"><time datetime="{{ entry.start }}">{{ entry.start | monthYear }}</time> &ndash; <time{% if entry.end %} datetime="{{ entry.end }}"{% endif %}>{{ entry.end | monthYearOrPresent }}</time></aside>
{{ renderDateRangeWithDuration(entry.start, entry.end) }}
</label>
<ul class="expandable-content">
{% if entry.positions %}
{% for position in entry.positions %}
<li>
<strong>{{ position.title }}</strong>
<aside class="date-range"><time datetime="{{ position.start }}">{{ position.start | monthYear }}</time> &ndash; <time{% if position.end %} datetime="{{ position.end }}"{% endif %}>{{ position.end | monthYearOrPresent }}</time></aside>
{{ renderDateRangeWithDuration(position.start, position.end) }}
<ul>
{% for highlight in position.highlights %}
<li>{{ highlight | safe }}</li>
Expand Down
48 changes: 48 additions & 0 deletions _includes/sections/experience.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import fs from 'node:fs'
import path from 'node:path'
import nunjucks from 'nunjucks'
import { describe, expect, it } from 'vitest'
import { formatDurationRange, formatMonthYear } from '../../src/shared/date.mjs'

const templatePath = path.join(process.cwd(), '_includes', 'sections', 'experience.njk')
const templateSource = fs.readFileSync(templatePath, 'utf8')

const env = new nunjucks.Environment(
new nunjucks.FileSystemLoader(path.join(process.cwd(), '_includes')),
)
env.addFilter('monthYear', (value) => formatMonthYear(value))
env.addFilter('monthYearOrPresent', (value) => (value ? formatMonthYear(value) : 'Present'))
env.addFilter('durationText', (startValue, endValue) => formatDurationRange(startValue, endValue))

describe('experience template', () => {
it('renders ended and ongoing duration markup correctly', () => {
const html = env.renderString(templateSource, {
resume: {
experience: [
{
id: 'ended-role',
title: 'Engineer',
start: '2021-06',
end: '2022-06',
company: { name: 'Ended Co' },
highlights: ['Shipped feature'],
},
{
id: 'ongoing-role',
title: 'Lead Engineer',
start: '2024-01',
company: { name: 'Ongoing Co' },
highlights: ['Running team'],
},
],
},
})

expect(html).toContain('<span class="duration">(1 year)</span>')
expect(html).toContain(
'<span class="duration duration--ongoing" data-start="2024-01" hidden></span>',
)
expect(html).toContain('June 2021')
expect(html).toContain('Present')
})
})
123 changes: 123 additions & 0 deletions assets/scripts/main.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import {
diffInMonths,
formatDuration,
getCurrentLocalYearMonth,
getDurationParts,
parseYearMonth,
} from '@shared/date.mjs'

function buildAnimatedNumber(document, value, { startAt = 0 } = {}) {
const number = document.createElement('span')
number.className = 'duration-number'
number.style.setProperty('--duration-digits', String(String(value).length))

const track = document.createElement('span')
track.className = 'duration-number-track'
track.style.setProperty('--duration-target', String(value - startAt))

for (let currentValue = startAt; currentValue <= value; currentValue += 1) {
const cell = document.createElement('span')
cell.className = 'duration-number-cell'
cell.textContent = String(currentValue)
track.append(cell)
}

number.append(track)
return number
}

function buildAnimatedDuration(document, totalMonths) {
const display = document.createElement('span')
display.className = 'duration-display'
display.setAttribute('aria-hidden', 'true')
display.append('(')

getDurationParts(totalMonths).forEach((part, index) => {
if (index > 0) {
display.append(', ')
}

const partNode = document.createElement('span')
partNode.className = 'duration-part'
partNode.append(
buildAnimatedNumber(document, part.value, {
startAt: 1,
}),
)
partNode.append(` ${part.label}`)
display.append(partNode)
})

display.append(' and counting...')
display.append(')')
return display
}

function setReducedMotionDuration(node, durationLabel) {
node.textContent = durationLabel
}

function setAnimatedDuration(document, node, durationLabel, totalMonths) {
const srText = document.createElement('span')
srText.className = 'visually-hidden'
srText.textContent = durationLabel

node.append(srText, buildAnimatedDuration(document, totalMonths))
}

function enhanceOngoingDurationNode(
node,
{ document, currentMonth, prefersReducedMotion, requestAnimationFrame },
) {
const startDate = parseYearMonth(node.getAttribute('data-start'))

if (!startDate) {
return
}

const totalMonths = diffInMonths(startDate, currentMonth)
const durationLabel = `(${formatDuration(totalMonths)} and counting...)`
node.textContent = ''

if (prefersReducedMotion) {
setReducedMotionDuration(node, durationLabel)
} else {
setAnimatedDuration(document, node, durationLabel, totalMonths)
}

node.hidden = false

if (!prefersReducedMotion) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
node.classList.add('is-visible')
})
})
}
}

export function enhanceOngoingDurations({
document,
now = new Date(),
prefersReducedMotion = false,
requestAnimationFrame = (callback) => callback(),
} = {}) {
const currentMonth = getCurrentLocalYearMonth(now)

for (const node of document.querySelectorAll('.duration--ongoing[data-start]')) {
enhanceOngoingDurationNode(node, {
document,
currentMonth,
prefersReducedMotion,
requestAnimationFrame,
})
}
}

if (typeof window !== 'undefined' && typeof document !== 'undefined') {
enhanceOngoingDurations({
document,
prefersReducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
requestAnimationFrame: window.requestAnimationFrame.bind(window),
})
}
121 changes: 121 additions & 0 deletions assets/scripts/main.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest'
import { enhanceOngoingDurations } from './main.js'

class FakeElement {
constructor(tagName) {
this.tagName = tagName
this.children = []
this.className = ''
this.attributes = new Map()
this.hidden = true
this._textContent = ''
this.style = {
properties: new Map(),
setProperty: (name, value) => {
this.style.properties.set(name, value)
},
}
this.classList = {
classes: new Set(),
add: (name) => {
this.classList.classes.add(name)
},
contains: (name) => this.classList.classes.has(name),
}
}

set textContent(value) {
this._textContent = value
this.children = []
}

get textContent() {
return `${this._textContent}${this.children.map((child) => child.textContent).join('')}`
}

setAttribute(name, value) {
this.attributes.set(name, String(value))
}

getAttribute(name) {
return this.attributes.get(name) ?? null
}

append(...nodes) {
for (const node of nodes) {
if (typeof node === 'string') {
const textNode = new FakeElement('#text')
textNode.textContent = node
this.children.push(textNode)
continue
}

this.children.push(node)
}
}
}

class FakeDocument {
constructor(nodes) {
this.nodes = nodes
}

createElement(tagName) {
return new FakeElement(tagName)
}

querySelectorAll(selector) {
if (selector === '.duration--ongoing[data-start]') {
return this.nodes
}

return []
}
}

function createDurationNode(startValue) {
const node = new FakeElement('span')
node.className = 'duration duration--ongoing'
node.setAttribute('data-start', startValue)
return node
}

describe('enhanceOngoingDurations', () => {
it('renders reduced-motion text for ongoing durations', () => {
const node = createDurationNode('2025-02')
const document = new FakeDocument([node])

enhanceOngoingDurations({
document,
now: new Date('2026-04-18T15:45:00.000Z'),
prefersReducedMotion: true,
})

expect(node.hidden).toBe(false)
expect(node.textContent).toBe('(1 year, 2 months and counting...)')
})

it('renders accessible text plus animated markup when motion is allowed', () => {
const node = createDurationNode('2025-02')
const document = new FakeDocument([node])
let animationScheduled = false

enhanceOngoingDurations({
document,
now: new Date('2026-04-18T15:45:00.000Z'),
requestAnimationFrame: (callback) => {
animationScheduled = true
callback()
},
})

expect(node.hidden).toBe(false)
expect(node.children).toHaveLength(2)
expect(node.children[0].className).toBe('visually-hidden')
expect(node.children[0].textContent).toBe('(1 year, 2 months and counting...)')
expect(node.children[1].className).toBe('duration-display')
expect(node.children[1].attributes.get('aria-hidden')).toBe('true')
expect(node.classList.contains('is-visible')).toBe(true)
expect(animationScheduled).toBe(true)
})
})
Loading