Skip to content

Commit ecb6099

Browse files
committed
Add Education block o resume
1 parent ac55873 commit ecb6099

8 files changed

Lines changed: 371 additions & 11 deletions

File tree

_TODO.md

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,3 @@ https://aws.plainenglish.io/how-to-build-a-chatbot-using-aws-lex-and-lambda-in-2
3535

3636
- Need to move the unsubscribe link into an Action and handle it entirely within our website instead of on Hubspot
3737
- Need to add a newsletter publishing workflow as an action, using the newsletter static segment imported from Hubspot
38-
39-
## Resume
40-
41-
- Need to have an Education block
42-
43-
## "Sticky" TOC
44-
45-
The TOC should be sticky as the page scrolls down

public/pdf/resume.pdf

28.7 KB
Binary file not shown.

src/components/Pages/Resume/index.astro

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { RenderedClient } from '@components/Pages/Resume/server'
44
import HeadContent from '@components/Head/index.astro'
55
import Contact from '@components/Pages/Resume/partials/Contact.astro'
66
import Controls from '@components/Pages/Resume/partials/Controls.astro'
7+
import Education from '@components/Pages/Resume/partials/Education.astro'
78
import Experience from '@components/Pages/Resume/partials/Experience.astro'
89
import Languages from '@components/Pages/Resume/partials/Languages.astro'
910
import Persona from '@components/Pages/Resume/partials/Persona.astro'
@@ -76,6 +77,10 @@ const { contactData, pageDescription, pageTitle, path, renderedClients, resume }
7677
<Languages languages={resume.languages} />
7778
</div>
7879

80+
<div>
81+
<Education education={resume.education} />
82+
</div>
83+
7984
<div>
8085
<Socials socials={contactData.company.social} />
8186
</div>
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
---
2+
import type { CollectionEntry } from 'astro:content'
3+
import Icon from '@components/Icon/index.astro'
4+
import List from '@components/List/index.astro'
5+
6+
export interface Props {
7+
education: CollectionEntry<'resume'>['data']['education']
8+
}
9+
10+
const { education } = Astro.props as Props
11+
12+
/** ISO month (YYYY-MM) for <time datetime>, tolerant of free-form input */
13+
const toIsoMonth = (value?: string): string | undefined => {
14+
if (!value) return undefined
15+
const parsed = new Date(value)
16+
if (Number.isNaN(parsed.getTime())) return undefined
17+
return `${parsed.getFullYear()}-${String(parsed.getMonth() + 1).padStart(2, '0')}`
18+
}
19+
20+
const startIso = toIsoMonth(education.startDate)
21+
const endIso = toIsoMonth(education.graduationDate)
22+
const highlightItems = (education.highlights ?? []).map(text => ({ text }))
23+
---
24+
25+
<section
26+
id="education"
27+
class="mt-6"
28+
itemscope
29+
itemref="resumeSubject"
30+
itemtype="http://schema.org/ItemList"
31+
>
32+
<h2
33+
class="text-sm font-mono font-bold tracking-widest text-content-offset print:text-content uppercase p-3 border-2 border-dashed border-gray-400 rounded-md"
34+
>
35+
Education
36+
</h2>
37+
<div
38+
class="mt-4 mx-2"
39+
itemprop="alumniOf"
40+
itemscope
41+
itemref="resumeSubject"
42+
itemtype="http://schema.org/CollegeOrUniversity"
43+
>
44+
{/** Degree title and dates row (mirrors the Experience role/dates row) */}
45+
<div class="flex flex-col md:flex-row md:items-baseline md:justify-between">
46+
<h3 class="text-xl font-bold text-primary-offset">
47+
{education.degree}
48+
</h3>
49+
{
50+
(education.startDate || education.graduationDate) && (
51+
<div class="text-md font-medium text-content-offset mt-0.5 flex gap-1.5 items-center">
52+
{education.startDate && <time datetime={startIso}>{education.startDate}</time>}
53+
{education.startDate && education.graduationDate && (
54+
<span class="text-trim/50">&mdash;</span>
55+
)}
56+
{education.graduationDate && <time datetime={endIso}>{education.graduationDate}</time>}
57+
</div>
58+
)
59+
}
60+
</div>
61+
{/** School name and location row (mirrors the Experience company/location row) */}
62+
<div class="text-primary-offset flex flex-wrap items-center gap-x-3 gap-y-1">
63+
<a
64+
class="inline-flex items-center text-danger-offset decoration-danger-offset transition-colors hover:text-danger hover:decoration-danger font-medium mb-1 print:text-content print:decoration-none"
65+
href={education.geolocationLink}
66+
>
67+
<span itemprop="name" class="font-semibold text-lg text-danger-offset print:text-content">
68+
{education.school}
69+
</span>
70+
</a>
71+
<div class="flex">
72+
<Icon icon="location" color="inherit" size={5} />
73+
<span itemprop="address" itemscope itemtype="http://schema.org/PostalAddress">
74+
<span itemprop="addressLocality">{education.campus}</span>
75+
</span>
76+
</div>
77+
</div>
78+
{highlightItems.length > 0 && <List variant="experience-list" items={highlightItems} />}
79+
</div>
80+
</section>
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
// @vitest-environment jsdom
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3+
import { initStickySidebar } from '@components/scripts/stickySidebar'
4+
5+
/**
6+
* The sticky sidebar pins SHORT sidebars just below the fixed chrome:
7+
* translateY = max(0, chromeBottom + topPadding - naturalTop).
8+
* These tests drive that path with a sidebar shorter than the viewport.
9+
*/
10+
11+
const SIDEBAR_HEIGHT = 400
12+
const TALL_SIDEBAR_HEIGHT = 1200
13+
const NATURAL_TOP = 100
14+
const CONTAINER_BOTTOM = 10_000
15+
16+
interface PageState {
17+
chromeBottom: number
18+
/** Document offset of the sidebar before transforms; natural top = docTop - scrollTop */
19+
docTop: number
20+
sidebarHeight: number
21+
}
22+
23+
let headerEl: HTMLElement
24+
let progressEl: HTMLElement
25+
let scroller: HTMLElement
26+
let container: HTMLElement
27+
let sidebar: HTMLElement
28+
let state: PageState
29+
30+
/** ResizeObserver test double: captures the callback so tests can fire it. */
31+
let roCallback: (() => void) | null
32+
let observeSpy: ReturnType<typeof vi.fn>
33+
let disconnectSpy: ReturnType<typeof vi.fn>
34+
35+
class FakeResizeObserver {
36+
constructor(callback: () => void) {
37+
roCallback = callback
38+
}
39+
observe = observeSpy
40+
unobserve = vi.fn()
41+
disconnect = disconnectSpy
42+
}
43+
44+
const rect = (top: number, height: number): DOMRect =>
45+
({
46+
top,
47+
bottom: top + height,
48+
height,
49+
left: 0,
50+
right: 300,
51+
width: 300,
52+
x: 0,
53+
y: top,
54+
toJSON: () => ({}),
55+
}) as DOMRect
56+
57+
/** Sidebar top tracks the scroll position plus any applied transform. */
58+
function currentSidebarTop(): number {
59+
const match = /translateY\((-?\d+(?:\.\d+)?)px\)/.exec(sidebar.style.transform)
60+
return state.docTop - scroller.scrollTop + (match ? Number(match[1]) : 0)
61+
}
62+
63+
/** Wait long enough for the rAF-debounced update to run. */
64+
const flushUpdate = (): Promise<void> => new Promise(resolve => setTimeout(resolve, 40))
65+
66+
function setupDom(): void {
67+
document.body.innerHTML = `
68+
<header class="header-fixed"><span id="header-child"></span></header>
69+
<div data-progress-bar></div>
70+
<div id="scroll-viewport"><div id="container"><aside id="sidebar"></aside></div></div>
71+
`
72+
headerEl = document.querySelector('.header-fixed') as HTMLElement
73+
progressEl = document.querySelector('[data-progress-bar]') as HTMLElement
74+
scroller = document.getElementById('scroll-viewport') as HTMLElement
75+
container = document.getElementById('container') as HTMLElement
76+
sidebar = document.getElementById('sidebar') as HTMLElement
77+
78+
state = { chromeBottom: 104, docTop: NATURAL_TOP, sidebarHeight: SIDEBAR_HEIGHT }
79+
80+
headerEl.getBoundingClientRect = () => rect(0, state.chromeBottom)
81+
progressEl.getBoundingClientRect = () => rect(0, 0)
82+
sidebar.getBoundingClientRect = () => rect(currentSidebarTop(), state.sidebarHeight)
83+
container.getBoundingClientRect = () => rect(-1000, CONTAINER_BOTTOM + 1000)
84+
85+
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1440 })
86+
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 900 })
87+
}
88+
89+
beforeEach(() => {
90+
roCallback = null
91+
observeSpy = vi.fn()
92+
disconnectSpy = vi.fn()
93+
vi.stubGlobal('ResizeObserver', FakeResizeObserver)
94+
setupDom()
95+
})
96+
97+
afterEach(() => {
98+
vi.unstubAllGlobals()
99+
document.body.innerHTML = ''
100+
})
101+
102+
describe('initStickySidebar chrome tracking', () => {
103+
it('pins a short sidebar below the measured chrome on init', () => {
104+
initStickySidebar(sidebar, container)
105+
// visibleTop = 104 + 16 = 120; naturalTop = 100 -> translateY(20px)
106+
expect(sidebar.style.transform).toBe('translateY(20px)')
107+
expect(observeSpy).toHaveBeenCalledWith(headerEl)
108+
expect(observeSpy).toHaveBeenCalledWith(progressEl)
109+
})
110+
111+
it('re-measures when the header resizes (WAAPI squish path)', async () => {
112+
initStickySidebar(sidebar, container)
113+
expect(sidebar.style.transform).toBe('translateY(20px)')
114+
115+
// Header squishes after the scroll stops: chrome bottom 104 -> 60
116+
state.chromeBottom = 60
117+
roCallback?.()
118+
await flushUpdate()
119+
120+
// visibleTop = 60 + 16 = 76 < naturalTop 100 -> back to natural position
121+
expect(sidebar.style.transform).toBe('translateY(0px)')
122+
})
123+
124+
it('re-measures when the header transform transition ends', async () => {
125+
initStickySidebar(sidebar, container)
126+
state.chromeBottom = 60
127+
headerEl.dispatchEvent(new Event('transitionend'))
128+
await flushUpdate()
129+
130+
expect(sidebar.style.transform).toBe('translateY(0px)')
131+
})
132+
133+
it('ignores transitionend events bubbling from header children', async () => {
134+
initStickySidebar(sidebar, container)
135+
const child = document.getElementById('header-child') as HTMLElement
136+
state.chromeBottom = 60
137+
child.dispatchEvent(new Event('transitionend', { bubbles: true }))
138+
await flushUpdate()
139+
140+
// Target filter rejects the event; stale transform remains
141+
expect(sidebar.style.transform).toBe('translateY(20px)')
142+
})
143+
144+
it('cleanup disconnects the observer and removes the transition listener', async () => {
145+
const destroy = initStickySidebar(sidebar, container)
146+
destroy()
147+
148+
expect(disconnectSpy).toHaveBeenCalledTimes(1)
149+
expect(sidebar.style.transform).toBe('')
150+
151+
state.chromeBottom = 60
152+
headerEl.dispatchEvent(new Event('transitionend'))
153+
await flushUpdate()
154+
expect(sidebar.style.transform).toBe('')
155+
})
156+
157+
it('still tracks scroll when ResizeObserver is unavailable', async () => {
158+
vi.stubGlobal('ResizeObserver', undefined)
159+
initStickySidebar(sidebar, container)
160+
expect(sidebar.style.transform).toBe('translateY(20px)')
161+
162+
state.chromeBottom = 60
163+
scroller.dispatchEvent(new Event('scroll'))
164+
await flushUpdate()
165+
expect(sidebar.style.transform).toBe('translateY(0px)')
166+
})
167+
})
168+
169+
describe('initStickySidebar tall sidebar pin tracking', () => {
170+
const scrollTo = (top: number): void => {
171+
scroller.scrollTop = top
172+
scroller.dispatchEvent(new Event('scroll'))
173+
}
174+
175+
it('re-attaches a top pin when the chrome moves without a scroll event', async () => {
176+
state.sidebarHeight = TALL_SIDEBAR_HEIGHT
177+
state.docTop = 600
178+
initStickySidebar(sidebar, container)
179+
180+
// Scroll down past the sidebar: it drifts, then bottom-pins
181+
scrollTo(800)
182+
await flushUpdate()
183+
expect(sidebar.style.transform).toBe('')
184+
scrollTo(1000)
185+
await flushUpdate()
186+
// bottom pin: 884 - 1200 - (600 - 1000) = 84
187+
expect(sidebar.style.transform).toBe('translateY(84px)')
188+
189+
// Scroll back up: releases from the bottom, then top-pins
190+
scrollTo(600)
191+
await flushUpdate()
192+
expect(sidebar.style.transform).toBe('translateY(84px)')
193+
scrollTo(500)
194+
await flushUpdate()
195+
// top pin: visibleTop 120 - naturalTop (600 - 500) = 20
196+
expect(sidebar.style.transform).toBe('translateY(20px)')
197+
198+
// Header EXPANDS after the scroll stops (chrome bottom 104 -> 140).
199+
// The top-pinned sidebar must track to the new pin line without a scroll.
200+
state.chromeBottom = 140
201+
roCallback?.()
202+
await flushUpdate()
203+
// visibleTop = 140 + 16 = 156 -> 156 - 100 = 56
204+
expect(sidebar.style.transform).toBe('translateY(56px)')
205+
206+
// Header then squishes below the natural position: clamped to natural
207+
state.chromeBottom = 60
208+
roCallback?.()
209+
await flushUpdate()
210+
expect(sidebar.style.transform).toBe('translateY(0px)')
211+
})
212+
})

0 commit comments

Comments
 (0)