prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

commit dc01470030d0a6f0a28a75352ed87c4f719d5deb
parent 53fff33034b0417ccad25003d3f5476acb08643a
Author: Damian Myrda <damian@prime8.dev>
Date:   Thu, 22 Oct 2026 20:38:39 +0000

web: experience timeline shows start, end, duration

lanes on a shared year axis, rails mark start and end, hover/focus lights a job's lane, rail and card together. tui and vfs use the same range and duration.

Diffstat:
Minternal/tui/content.go | 2+-
Minternal/tui/helpers.go | 14+++-----------
Minternal/utils/period.go | 156++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Ainternal/utils/period_test.go | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minternal/vfs/vfs.go | 2+-
Minternal/web/renderer.go | 52++++++++++++++++++++++++++++++++--------------------
Minternal/web/scripts/modules/experience.js | 158++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Minternal/web/styles/experience.css | 228++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Minternal/web/templates/partials/experience.gohtml | 37++++++++++++++++++++++++++++++-------
9 files changed, 544 insertions(+), 183 deletions(-)

diff --git a/internal/tui/content.go b/internal/tui/content.go @@ -197,7 +197,7 @@ func (m portfolioModel) experiencePage() string { edge = " " } body := dimStyle.Render(experienceGraphGutter + edge + " ") - label := fmt.Sprintf("%-4s %-20s %s%s", utilsYearLabel(e), fitRunes(e.Company, 20), fitRunes(e.Role, 22), decorator) + label := fmt.Sprintf("%-20s %s%s", fitRunes(e.Company, 20), fitRunes(e.Role, 22), decorator) rangeLabel := dateRangeLabel(e) if i == m.experienceIndex { rows = append(rows, selectedRowStyle.Render(gutter+"* "+label)) diff --git a/internal/tui/helpers.go b/internal/tui/helpers.go @@ -113,16 +113,8 @@ func panelStyleWithoutBottom(style lipgloss.Style) lipgloss.Style { return style.Border(lipgloss.NormalBorder(), true, true, false, true) } -func utilsYearLabel(exp config.Experience) string { - if exp.Current { - return "now" - } - if exp.EndDate != nil { - return exp.EndDate.Format("2006") - } - return exp.StartDate.Format("2006") -} - +// dateRangeLabel is "oct 2024 – feb 2026 · 1 yr 5 mo". func dateRangeLabel(exp config.Experience) string { - return utils.FormatMonthYear(exp.StartDate) + " -> " + utils.FormatEndDate(exp.EndDate, exp.Current) + return utils.FormatRange(exp.StartDate, exp.EndDate, exp.Current) + " · " + + utils.FormatDurationShort(exp.StartDate, exp.EndDate, exp.Current) } diff --git a/internal/utils/period.go b/internal/utils/period.go @@ -6,45 +6,27 @@ import ( "time" ) -var monthNames = []string{ - "january", - "february", - "march", - "april", - "may", - "june", - "july", - "august", - "september", - "october", - "november", - "december", -} - -func FormatMonthYear(t time.Time) string { - month := int(t.Month()) - 1 - if month < 0 || month >= len(monthNames) { - return strings.ToLower(t.Format("Jan 2006")) - } - - return fmt.Sprintf("%s %s", monthNames[month], t.Format("2006")) -} +// now is the clock "present" is measured against; tests pin it. +var now = time.Now -func FormatEndDate(endDate *time.Time, current bool) string { - if current || endDate == nil { - return "present" +// spanEnd is the last month of a role: its end date, or this month while +// it is current. +func spanEnd(endDate *time.Time, current bool) time.Time { + if !current && endDate != nil { + return *endDate } + return now() +} - return FormatMonthYear(*endDate) +// monthIndex counts months from year 0, so differences are month counts. +func monthIndex(t time.Time) int { + return t.Year()*12 + int(t.Month()) - 1 } +// MonthsDuration counts the calendar months a role covers, both ends +// included (feb 2026 -> feb 2026 is 1 month), the way resumes count. func MonthsDuration(startDate time.Time, endDate *time.Time, current bool) int { - end := time.Now() - if !current && endDate != nil { - end = *endDate - } - - months := (end.Year()-startDate.Year())*12 + int(end.Month()-startDate.Month()) + 1 + months := monthIndex(spanEnd(endDate, current)) - monthIndex(startDate) + 1 if months < 1 { return 1 } @@ -52,46 +34,104 @@ func MonthsDuration(startDate time.Time, endDate *time.Time, current bool) int { return months } -func MonthWord(months int) string { - if months == 1 { - return "month" +// FormatShortMonthYear is "feb 2026". +func FormatShortMonthYear(t time.Time) string { + return strings.ToLower(t.Format("Jan 2006")) +} + +// FormatShortEndDate is "present" for a current role, else "feb 2026". +func FormatShortEndDate(endDate *time.Time, current bool) string { + if current || endDate == nil { + return "present" } + return FormatShortMonthYear(*endDate) +} - return "months" +// FormatRange is "oct 2024 – feb 2026" or "feb 2026 – present". +func FormatRange(startDate time.Time, endDate *time.Time, current bool) string { + return FormatShortMonthYear(startDate) + " – " + FormatShortEndDate(endDate, current) } -func FormatDuration(startDate time.Time, endDate *time.Time, current bool) string { +// FormatDurationShort is "1 yr 5 mo", "2 yr" or "9 mo". +func FormatDurationShort(startDate time.Time, endDate *time.Time, current bool) string { months := MonthsDuration(startDate, endDate, current) - years := months / 12 - remainingMonths := months % 12 - - if years == 0 { - return fmt.Sprintf("%d %s", months, MonthWord(months)) + years, rest := months/12, months%12 + switch { + case years == 0: + return fmt.Sprintf("%d mo", rest) + case rest == 0: + return fmt.Sprintf("%d yr", years) + default: + return fmt.Sprintf("%d yr %d mo", years, rest) } +} - if remainingMonths == 0 { - return fmt.Sprintf("%d %s", years, yearWord(years)) - } +// MonthStamp is the machine-readable month for <time datetime>: "2026-02". +func MonthStamp(t time.Time) string { + return t.Format("2006-01") +} - return fmt.Sprintf("%d %s %d %s", years, yearWord(years), remainingMonths, MonthWord(remainingMonths)) +// EndStamp is MonthStamp of a role's last month (this month while current). +func EndStamp(endDate *time.Time, current bool) string { + return MonthStamp(spanEnd(endDate, current)) } -func DurationHasYear(startDate time.Time, endDate *time.Time, current bool) bool { - return MonthsDuration(startDate, endDate, current) >= 12 +// Span is one role on a timeline. +type Span struct { + Start time.Time + End *time.Time + Current bool } -func yearWord(years int) string { - if years == 1 { - return "year" - } +// Tick is a year boundary on a timeline axis, At in [0,1]. +type Tick struct { + Label string + At float64 +} - return "years" +// Axis maps months onto [0,1]: from january of the earliest start year to +// the end of the latest month any role reaches (this month while one is +// current). A role fills its start month through its end month. +type Axis struct { + first, months int + Ticks []Tick + // EndLabel marks the right edge: "now" while a role is current, unless + // the last year tick sits too close to it to fit both labels. + EndLabel string } -func TimelineYear(endDate *time.Time, current bool) string { - if current || endDate == nil { - return "now" +func NewAxis(spans []Span) Axis { + if len(spans) == 0 { + return Axis{months: 1} + } + first, last := monthIndex(spans[0].Start), monthIndex(spanEnd(spans[0].End, spans[0].Current)) + current := false + for _, s := range spans { + first = min(first, monthIndex(s.Start)) + last = max(last, monthIndex(spanEnd(s.End, s.Current))) + current = current || s.Current } + first -= first % 12 // january + a := Axis{first: first, months: max(last+1-first, 1)} + for m := first; m < first+a.months; m += 12 { + a.Ticks = append(a.Ticks, Tick{Label: fmt.Sprint(m / 12), At: a.at(m)}) + } + if current && a.Ticks[len(a.Ticks)-1].At < 0.85 { + a.EndLabel = "now" + } + return a +} + +func (a Axis) at(month int) float64 { + return float64(month-a.first) / float64(a.months) +} + +// From is where a role's bar starts. +func (a Axis) From(startDate time.Time) float64 { + return a.at(monthIndex(startDate)) +} - return endDate.Format("2006") +// To is where a role's bar ends: the end of its last month. +func (a Axis) To(endDate *time.Time, current bool) float64 { + return a.at(monthIndex(spanEnd(endDate, current)) + 1) } diff --git a/internal/utils/period_test.go b/internal/utils/period_test.go @@ -0,0 +1,78 @@ +package utils + +import ( + "testing" + "time" +) + +func month(s string) time.Time { + t, err := time.Parse("2006-01", s) + if err != nil { + panic(err) + } + return t +} + +func monthPtr(s string) *time.Time { + t := month(s) + return &t +} + +func pinNow(t *testing.T, s string) { + t.Helper() + old := now + now = func() time.Time { return month(s).AddDate(0, 0, 21) } + t.Cleanup(func() { now = old }) +} + +func TestRangeAndDuration(t *testing.T) { + pinNow(t, "2026-10") + for _, c := range []struct { + start string + end *time.Time + current bool + rng, dur string + }{ + {"2026-02", nil, true, "feb 2026 – present", "9 mo"}, + {"2024-10", monthPtr("2026-02"), false, "oct 2024 – feb 2026", "1 yr 5 mo"}, + {"2024-06", monthPtr("2024-08"), false, "jun 2024 – aug 2024", "3 mo"}, + {"2024-01", monthPtr("2025-12"), false, "jan 2024 – dec 2025", "2 yr"}, + {"2026-03", monthPtr("2026-03"), false, "mar 2026 – mar 2026", "1 mo"}, + } { + s := month(c.start) + if got := FormatRange(s, c.end, c.current); got != c.rng { + t.Errorf("FormatRange(%s) = %q, want %q", c.start, got, c.rng) + } + if got := FormatDurationShort(s, c.end, c.current); got != c.dur { + t.Errorf("FormatDurationShort(%s) = %q, want %q", c.start, got, c.dur) + } + } +} + +func TestAxis(t *testing.T) { + pinNow(t, "2026-10") + a := NewAxis([]Span{ + {Start: month("2026-02"), Current: true}, + {Start: month("2024-10"), End: monthPtr("2026-02")}, + {Start: month("2024-06"), End: monthPtr("2024-08")}, + }) + // jan 2024 .. oct 2026 inclusive = 34 months + if a.months != 34 { + t.Fatalf("months = %d, want 34", a.months) + } + if len(a.Ticks) != 3 || a.Ticks[0].Label != "2024" || a.Ticks[2].Label != "2026" { + t.Fatalf("ticks = %+v", a.Ticks) + } + if got := a.Ticks[1].At; got != 12.0/34 { + t.Errorf("2025 tick at %v", got) + } + if a.EndLabel != "now" { + t.Errorf("EndLabel = %q, want now", a.EndLabel) + } + if got := a.To(nil, true); got != 1 { + t.Errorf("current role ends at %v, want 1", got) + } + if from, to := a.From(month("2024-06")), a.To(monthPtr("2024-08"), false); from != 5.0/34 || to != 8.0/34 { + t.Errorf("amplifier spans %v..%v", from, to) + } +} diff --git a/internal/vfs/vfs.go b/internal/vfs/vfs.go @@ -215,7 +215,7 @@ func aboutFile(src Source) string { func experienceFile(e appconfig.Experience) string { lines := []string{ e.Role + " @ " + e.Company, - utils.FormatMonthYear(e.StartDate) + " -> " + utils.FormatEndDate(e.EndDate, e.Current), + utils.FormatRange(e.StartDate, e.EndDate, e.Current) + " (" + utils.FormatDurationShort(e.StartDate, e.EndDate, e.Current) + ")", "", } lines = append(lines, e.Description...) diff --git a/internal/web/renderer.go b/internal/web/renderer.go @@ -90,26 +90,29 @@ func New() (*Renderer, error) { } funcs := template.FuncMap{ - "tabPath": utils.TabPath, - "tabURL": utils.TabURL, - "tagClass": utils.TagClass, - "tagColor": func(label string) template.CSS { return template.CSS(utils.TagColor(label)) }, - "shellPath": ShellPath, - "windowTitle": func(p string) string { return utils.WindowTitle(ShellPath(p)) }, - "shellUser": func() string { return vfs.FallbackUser }, - "tagCSS": func() template.CSS { return template.CSS(utils.TagCSS()) }, - "inc": func(i int) int { return i + 1 }, - "techMeta": utils.TechMeta, - "formatMonthYear": utils.FormatMonthYear, - "formatEndDate": utils.FormatEndDate, - "formatDuration": utils.FormatDuration, - "durationHasYear": utils.DurationHasYear, - "timelineYear": utils.TimelineYear, - "toHTML": toHTML, - "assetVersion": AssetVersion, - "gitCloneURL": config.GitCloneURL, - "gitSSHCloneURL": config.GitSSHCloneURL, - "wakaTimeUser": func() string { return config.WakaTimeUser }, + "tabPath": utils.TabPath, + "tabURL": utils.TabURL, + "tagClass": utils.TagClass, + "tagColor": func(label string) template.CSS { return template.CSS(utils.TagColor(label)) }, + "shellPath": ShellPath, + "windowTitle": func(p string) string { return utils.WindowTitle(ShellPath(p)) }, + "shellUser": func() string { return vfs.FallbackUser }, + "tagCSS": func() template.CSS { return template.CSS(utils.TagCSS()) }, + "inc": func(i int) int { return i + 1 }, + "techMeta": utils.TechMeta, + "shortMonthYear": utils.FormatShortMonthYear, + "shortEndDate": utils.FormatShortEndDate, + "formatRange": utils.FormatRange, + "shortDuration": utils.FormatDurationShort, + "monthStamp": utils.MonthStamp, + "endStamp": utils.EndStamp, + "experienceAxis": experienceAxis, + "pct": func(f float64) string { return strconv.FormatFloat(f*100, 'f', 3, 64) + "%" }, + "toHTML": toHTML, + "assetVersion": AssetVersion, + "gitCloneURL": config.GitCloneURL, + "gitSSHCloneURL": config.GitSSHCloneURL, + "wakaTimeUser": func() string { return config.WakaTimeUser }, "inlineCSS": func(name string) template.CSS { if css, ok := inlineStyles[name]; ok { return css @@ -190,3 +193,12 @@ func loadInlineStyles() (map[string]template.CSS, error) { func ShellPath(path string) string { return "~" + strings.TrimRight(path, "/") } + +// experienceAxis lays every role on one month axis for the timeline strip. +func experienceAxis(exps []config.Experience) utils.Axis { + spans := make([]utils.Span, len(exps)) + for i, e := range exps { + spans[i] = utils.Span{Start: e.StartDate, End: e.EndDate, Current: e.Current} + } + return utils.NewAxis(spans) +} diff --git a/internal/web/scripts/modules/experience.js b/internal/web/scripts/modules/experience.js @@ -5,19 +5,41 @@ const experienceHandlers = { popstate: null, }; +// One job is lit at a time: its timeline lane, rail and card together. +// The selected job (url, click/tap, arrow keys) is lit unless a mouse is +// over, or focus is on, another job, which lights while it stays there. +// Moving across the gaps between jobs keeps the last one lit, so nothing +// blinks; leaving the list goes back to the selection. export function initExperience() { const section = document.getElementById("experience"); if (!section) return; - const cards = Array.from(section.querySelectorAll(".exp-card")); - const timelines = Array.from(section.querySelectorAll(".exp-timeline")); const rows = Array.from(section.querySelectorAll(".exp-row")); - if (cards.length === 0) return; + const lanes = Array.from(section.querySelectorAll(".exp-lane")); + const scroller = section.querySelector(".experience-detail"); + const chart = section.querySelector(".exp-chart"); + if (rows.length === 0 || !scroller) return; let selectedIndex = 0; + let previewIndex = -1; + let litIndex = -1; + + function paint() { + const lit = previewIndex >= 0 ? previewIndex : selectedIndex; + if (lit !== litIndex) { + litIndex = lit; + rows.forEach((row, i) => row.classList.toggle("active", i === lit)); + lanes.forEach((lane, i) => lane.classList.toggle("active", i === lit)); + } + lanes.forEach((lane, i) => + lane.setAttribute("aria-current", String(i === selectedIndex)), + ); + } - function experienceIDAt(index) { - return cards[index]?.dataset.id || rows[index]?.dataset.id || ""; + function indexOf(target) { + const el = target?.closest?.(".exp-row, .exp-lane"); + if (!el || !section.contains(el)) return -1; + return Number(el.dataset.index); } function indexFromPath() { @@ -25,11 +47,11 @@ export function initExperience() { window.location.pathname.match(/^\/experience\/([^/]+)$/)?.[1] || "", ); if (!id) return -1; - return cards.findIndex((card) => card.dataset.id === id); + return rows.findIndex((row) => row.dataset.id === id); } function updateExperienceURL(index) { - const id = experienceIDAt(index); + const id = rows[index]?.dataset.id; if (!id) return; const path = `/experience/${encodeURIComponent(id)}`; @@ -39,59 +61,71 @@ export function initExperience() { } function scrollSelectionIntoView(index) { - const target = rows[index] || cards[index]; + const target = rows[index]; if (!target) return; - const scroller = section.querySelector(".experience-detail"); - if (!scroller || !scroller.contains(target)) { - target.scrollIntoView({ - behavior: "smooth", - block: "center", - inline: "nearest", - }); - return; - } - + // the sticky timeline strip covers the top of the list + const inset = + chart && getComputedStyle(chart).position === "sticky" + ? chart.offsetHeight + : 0; const scrollerRect = scroller.getBoundingClientRect(); const targetRect = target.getBoundingClientRect(); - const isAbove = targetRect.top < scrollerRect.top + 12; + const top = scrollerRect.top + inset; + const isAbove = targetRect.top < top + 12; const isBelow = targetRect.bottom > scrollerRect.bottom - 12; if (!isAbove && !isBelow) return; - const targetTop = - targetRect.top - scrollerRect.top + scroller.scrollTop - 8; + const targetTop = targetRect.top - top + scroller.scrollTop - 8; scroller.scrollTo({ top: Math.max(targetTop, 0), behavior: "smooth" }); } - function select(index, updateURL = true, scrollIntoView = false) { + function select(index, { updateURL = true, scroll = false } = {}) { + if (index < 0 || index >= rows.length) return; selectedIndex = index; - cards.forEach((card, i) => card.classList.toggle("selected", i === index)); - timelines.forEach((timeline, i) => - timeline.classList.toggle("active", i === index), - ); - - if (scrollIntoView) { - scrollSelectionIntoView(index); - } + paint(); + if (scroll) scrollSelectionIntoView(index); + if (updateURL) updateExperienceURL(index); + } - if (updateURL) { - updateExperienceURL(index); - } + function preview(index) { + if (index === previewIndex) return; + previewIndex = index; + paint(); } - cards.forEach((card, i) => { - if (card.dataset.boundHover !== "1") { - card.dataset.boundHover = "1"; - card.addEventListener("mouseenter", () => select(i)); - } - }); + if (section.dataset.boundTimeline !== "1") { + section.dataset.boundTimeline = "1"; - timelines.forEach((timeline, i) => { - if (timeline.dataset.boundHover !== "1") { - timeline.dataset.boundHover = "1"; - timeline.addEventListener("mouseenter", () => select(i)); - } - }); + // mouse/pen only: a tap fires pointerover too, but it should select + scroller.addEventListener("pointerover", (e) => { + if (e.pointerType === "touch") return; + const index = indexOf(e.target); + if (index >= 0) preview(index); + }); + scroller.addEventListener("pointerleave", () => preview(-1)); + + scroller.addEventListener("focusin", (e) => { + const index = indexOf(e.target); + if (index >= 0) preview(index); + }); + scroller.addEventListener("focusout", (e) => { + if (indexOf(e.relatedTarget) < 0) preview(-1); + }); + + scroller.addEventListener("click", (e) => { + const lane = e.target.closest(".exp-lane"); + if (lane) { + // handled here, so the app-wide link handler leaves it alone + e.preventDefault(); + previewIndex = -1; + select(Number(lane.dataset.index), { scroll: true }); + return; + } + const index = indexOf(e.target); + if (index >= 0 && index !== selectedIndex) select(index); + }); + } if (experienceHandlers.keydown) { document.removeEventListener("keydown", experienceHandlers.keydown); @@ -101,19 +135,16 @@ export function initExperience() { if (!section.isConnected || window.isLightboxOpen?.()) return; if (isEditableTarget(e.target)) return; - switch (e.key) { - case "ArrowUp": - e.preventDefault(); - if (selectedIndex > 0) select(selectedIndex - 1, true, true); - break; - case "ArrowDown": - e.preventDefault(); - if (selectedIndex < cards.length - 1) - select(selectedIndex + 1, true, true); - break; - default: - break; - } + let next = -1; + if (e.key === "ArrowUp") next = selectedIndex - 1; + else if (e.key === "ArrowDown") next = selectedIndex + 1; + else return; + + e.preventDefault(); + if (next < 0 || next >= rows.length) return; + // the keyboard wins over a resting mouse until it moves again + previewIndex = -1; + select(next, { scroll: true }); }; document.addEventListener("keydown", experienceHandlers.keydown); @@ -125,9 +156,7 @@ export function initExperience() { experienceHandlers.popstate = () => { if (!section.isConnected) return; const index = indexFromPath(); - if (index >= 0) { - select(index, false); - } + if (index >= 0) select(index, { updateURL: false }); }; window.addEventListener("popstate", experienceHandlers.popstate); @@ -148,12 +177,11 @@ export function initExperience() { }); }); - const selectedCard = section.querySelector(".exp-card.selected") || cards[0]; - const initialIndex = Math.max(cards.indexOf(selectedCard), 0); const pathIndex = indexFromPath(); if (pathIndex >= 0) { - select(pathIndex, false); + select(pathIndex, { updateURL: false }); } else { - select(initialIndex); + const rendered = rows.findIndex((row) => row.classList.contains("active")); + select(Math.max(rendered, 0)); } } diff --git a/internal/web/styles/experience.css b/internal/web/styles/experience.css @@ -35,25 +35,179 @@ min-width: 0; display: flex; flex-direction: column; + transition: border-color 80ms linear; } -.exp-card.selected { +.exp-row.active .exp-card { border-color: var(--accent); } /* - * Terminal graph rail: + * Timeline strip: one lane per job on a shared month axis. * - * @ [now] <- current job (jj's working copy) + * hark | | | ======| 9 mo + * mandolin | ==|=======|== | 1 yr 5 mo + * amplifier | == | | | 3 mo + * 2024 2025 2026 now + * + * A bar fills its start month through its end month. Each lane is a link + * to its job and lights up together with the job's card. + */ +.exp-chart { + --name-w: 18ch; + --dur-w: 10ch; + position: sticky; + top: -4px; + z-index: 1; + display: grid; + grid-template-columns: var(--name-w) minmax(0, 1fr) var(--dur-w); + column-gap: 2ch; + /* line the lanes up with the card text, past the graph rail; the strip's + background still covers the rail as the list scrolls under it */ + padding: 12px 24px 8px calc(12ch + 24px); + margin-top: -4px; + background: var(--bg); + border-bottom: 1px solid var(--border); + font-size: var(--fs-14); + line-height: 1.5; + user-select: none; +} + +.chart-grid { + grid-column: 2; + position: relative; + pointer-events: none; +} + +.grid-line { + position: absolute; + top: 0; + bottom: 0; + left: var(--at); + width: 1px; + background: var(--border); +} + +.exp-lane { + grid-column: 1 / -1; + display: grid; + grid-template-columns: subgrid; + align-items: center; + color: var(--fg-dim); + text-decoration: none; + border-radius: 2px; + outline-offset: 2px; + -webkit-tap-highlight-color: transparent; +} + +.exp-lane:hover { + text-decoration: none; +} + +.exp-lane:focus-visible { + outline: 1px solid var(--accent); +} + +.lane-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lane-dur { + text-align: right; + white-space: nowrap; +} + +.lane-track { + position: relative; + height: 1.5em; +} + +.exp-bar { + position: absolute; + top: 50%; + left: var(--from); + width: calc(var(--to) - var(--from)); + min-width: 4px; + height: 6px; + transform: translateY(-50%); + background: var(--fg-dim); + transition: background-color 80ms linear; +} + +/* start and end caps: |=====| */ +.exp-bar::before, +.exp-bar::after { + content: ""; + position: absolute; + top: -4px; + bottom: -4px; + width: 2px; + background: inherit; +} + +.exp-bar::before { + left: 0; +} + +.exp-bar::after { + right: 0; +} + +/* still running: no end cap, the bar runs into the "now" edge */ +.exp-bar.current::after { + display: none; +} + +.exp-lane.active { + color: var(--accent); +} + +.exp-lane.active .exp-bar { + background: var(--accent); +} + +.chart-axis { + grid-column: 2; + color: var(--fg-dim); + font-size: var(--fs-12); +} + +.axis-labels { + position: relative; + display: block; + height: 1.5em; +} + +.axis-tick { + position: absolute; + left: var(--at); + padding-left: 4px; + white-space: nowrap; +} + +.axis-end { + position: absolute; + right: 0; + white-space: nowrap; +} + +/* + * Terminal graph rail, one segment per job: + * + * @ now <- end of the job (jj's working copy for the current one) * │ <- .graph-rail: 1px line through the node's character cell - * * [2026] <- past jobs + * + feb 2026 <- start of the job + * │ <- across the gap to the next (older) job + * * feb 2026 * - * The selected job's node and year go accent, its line accent-dim. + * The active job's nodes and dates go accent, its line accent-dim. */ .exp-timeline { --row: 1.5em; flex-shrink: 0; - width: 10ch; + width: 12ch; position: relative; padding-top: 24px; font-size: var(--fs-14); @@ -67,22 +221,28 @@ user-select: none; } -.graph-head { +.graph-head, +.graph-foot { height: var(--row); white-space: pre; } -.graph-node { - color: var(--fg-dim); +.graph-foot { + position: absolute; + left: 0; + bottom: 24px; } -.timeline-year { +.graph-node, +.graph-date { color: var(--fg-dim); + transition: color 80ms linear; } /* hairline through the node column */ .graph-rail, -.exp-row + .exp-row .exp-timeline::before { +.exp-row + .exp-row .exp-timeline::before, +.exp-row:not(:last-child) .exp-timeline::after { position: absolute; /* centre of the node's character cell, so the line runs through it */ left: calc(0.5ch - 0.5px); @@ -90,10 +250,18 @@ background: var(--border); } -/* from under this job's node to the bottom of its row */ +/* from under the end node to above the start node */ .graph-rail { top: calc(24px + var(--row)); + bottom: calc(24px + var(--row)); + transition: background-color 80ms linear; +} + +/* from under the start node to the bottom of the row */ +.exp-row:not(:last-child) .exp-timeline::after { + content: ""; bottom: 0; + height: 24px; } /* across the gap between cards, down to the next node */ @@ -103,20 +271,25 @@ height: calc(16px + 24px); } -/* oldest job: nothing below it */ -.exp-row:last-child .graph-rail { - display: none; -} - -.exp-timeline.active .graph-node, -.exp-timeline.active .timeline-year { +.exp-row.active .graph-node, +.exp-row.active .graph-date { color: var(--accent); } -.exp-timeline.active .graph-rail { +.exp-row.active .graph-rail { background: var(--accent-dim); } +@media (prefers-reduced-motion: reduce) { + .exp-card, + .exp-bar, + .graph-node, + .graph-date, + .graph-rail { + transition: none; + } +} + .exp-card-header { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -282,6 +455,21 @@ display: none; } + .exp-chart { + --name-w: 9ch; + --dur-w: 9ch; + position: static; + column-gap: 1ch; + margin: 0; + padding: 0 0 8px; + border-bottom: 0; + } + + .lane-track, + .exp-lane { + min-height: 2em; + } + .exp-card { padding: 16px; } diff --git a/internal/web/templates/partials/experience.gohtml b/internal/web/templates/partials/experience.gohtml @@ -2,14 +2,37 @@ <section id="experience" class="page"> <article class="experience-layout"> <div class="experience-detail"> + {{$axis := experienceAxis .Experience}} + <nav class="exp-chart" aria-label="experience timeline"> + <div class="chart-grid" aria-hidden="true" style="grid-row: 1 / span {{inc (len .Experience)}}"> + {{range $axis.Ticks}}<span class="grid-line" style="--at: {{pct .At}}"></span>{{end}} + </div> + {{range $index, $exp := .Experience}} + {{$on := or (eq $exp.ID $.SelectedExperienceID) (and (not $.SelectedExperienceID) (eq $index 0))}} + <a class="exp-lane{{if $on}} active{{end}}" style="grid-row: {{inc $index}}" href="/experience/{{$exp.ID}}" data-index="{{$index}}" data-id="{{$exp.ID}}" aria-controls="exp-{{$exp.ID}}" aria-current="{{if $on}}true{{else}}false{{end}}" aria-label="{{$exp.Company}}, {{$exp.Role}}: {{formatRange $exp.StartDate $exp.EndDate $exp.Current}}, {{shortDuration $exp.StartDate $exp.EndDate $exp.Current}}" title="{{formatRange $exp.StartDate $exp.EndDate $exp.Current}}"> + <span class="lane-name">{{$exp.Company}}</span> + <span class="lane-track"><span class="exp-bar{{if $exp.Current}} current{{end}}" style="--from: {{pct ($axis.From $exp.StartDate)}}; --to: {{pct ($axis.To $exp.EndDate $exp.Current)}}"></span></span> + <span class="lane-dur">{{shortDuration $exp.StartDate $exp.EndDate $exp.Current}}</span> + </a> + {{end}} + <div class="chart-axis" aria-hidden="true" style="grid-row: {{inc (len .Experience)}}"> + <span class="axis-labels"> + {{range $axis.Ticks}}<span class="axis-tick" style="--at: {{pct .At}}">{{.Label}}</span>{{end}} + {{if $axis.EndLabel}}<span class="axis-end">{{$axis.EndLabel}}</span>{{end}} + </span> + </div> + </nav> + {{range $index, $exp := .Experience}} - <div class="exp-row" data-index="{{$index}}" data-id="{{$exp.ID}}"> - <div class="exp-timeline{{if $.SelectedExperienceID}}{{if eq $exp.ID $.SelectedExperienceID}} active{{end}}{{else}}{{if eq $index 0}} active{{end}}{{end}}" data-index="{{$index}}"> - <div class="graph-head"><span class="graph-node" aria-hidden="true">{{if $exp.Current}}@{{else}}*{{end}}</span> <span class="timeline-year">[{{timelineYear $exp.EndDate $exp.Current}}]</span></div> - <span class="graph-rail" aria-hidden="true"></span> + {{$on := or (eq $exp.ID $.SelectedExperienceID) (and (not $.SelectedExperienceID) (eq $index 0))}} + <div class="exp-row{{if $on}} active{{end}}" data-index="{{$index}}" data-id="{{$exp.ID}}"> + <div class="exp-timeline" aria-hidden="true"> + <div class="graph-head"><span class="graph-node">{{if $exp.Current}}@{{else}}*{{end}}</span> <span class="graph-date">{{if $exp.Current}}now{{else}}{{shortEndDate $exp.EndDate $exp.Current}}{{end}}</span></div> + <span class="graph-rail"></span> + <div class="graph-foot"><span class="graph-node">+</span> <span class="graph-date">{{shortMonthYear $exp.StartDate}}</span></div> </div> - <div class="exp-card{{if $.SelectedExperienceID}}{{if eq $exp.ID $.SelectedExperienceID}} selected{{end}}{{else}}{{if eq $index 0}} selected{{end}}{{end}}" data-index="{{$index}}" data-id="{{$exp.ID}}"> + <div class="exp-card" id="exp-{{$exp.ID}}" data-index="{{$index}}" data-id="{{$exp.ID}}"> <div class="exp-card-header"> <div class="exp-card-title"> {{if $exp.Logo}} @@ -25,8 +48,8 @@ {{end}} </div> <div class="exp-card-meta"> - <span class="exp-dates">{{formatMonthYear $exp.StartDate}} -> {{formatEndDate $exp.EndDate $exp.Current}}</span> - <span class="exp-duration">{{formatDuration $exp.StartDate $exp.EndDate $exp.Current}}</span> + <span class="exp-dates"><time datetime="{{monthStamp $exp.StartDate}}">{{shortMonthYear $exp.StartDate}}</time> – {{if $exp.Current}}<span class="exp-present">present</span>{{else}}<time datetime="{{endStamp $exp.EndDate $exp.Current}}">{{shortEndDate $exp.EndDate $exp.Current}}</time>{{end}}</span> + <span class="exp-duration">{{shortDuration $exp.StartDate $exp.EndDate $exp.Current}}</span> {{if $exp.URL}}<a class="external-link" href="{{$exp.URL}}" target="_blank" rel="noopener noreferrer">{{$exp.URL}} -></a>{{end}} </div> </div>