feat(xp): convert experience timeline to kanban board#34
Conversation
Replace the vertical timeline layout with a kanban-style board to present experience entries as columns and cards, enabling grouping by decade or role and interactive card expansion. This change was requested by the prompt "what about a kanban style?" and reorganizes the template, script, and styles to: - Render columns computed from experiences (decade or role) and map experiences to kanban cards. - Add UI controls (view toggle), card expansion state, total/stats footer, and helpers for formatting, grouping, and computing totals. - Replace previous BadgeGroup usage and timeline-specific logic with kanban-specific components, computed columns, and new CSS for responsive board, columns, cards, headers, and stats.
WalkthroughThe ExperiencePage component underwent a complete restructuring from a timeline-based layout with a left sidebar to a Kanban-board layout. The new design organizes experiences into columns grouped by decade or role, displays each experience as a card with tags and duration, and supports per-card expansion to reveal additional details. Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes
Poem
Pre-merge checks and finishing touches✅ Passed checks (3 passed)
✨ Finishing touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (3)
src/pages/ExperiencePage.vue (3)
89-103: Avoid repeated parsing of technologies string.
parseTechnologies(exp.technologies)is called three times for the same card. This parses the same string multiple times per render cycle.Consider computing the parsed technologies once in the card scope or creating a computed map:
- <span - v-for="tech in parseTechnologies(exp.technologies).slice(0, 5)" - :key="tech" - class="tech-badge" - > - {{ tech }} - </span> - <span - v-if="parseTechnologies(exp.technologies).length > 5" - class="tech-more" - > - +{{ parseTechnologies(exp.technologies).length - 5 }} - </span> + <template :set="techs = parseTechnologies(exp.technologies)"> + <span + v-for="tech in techs.slice(0, 5)" + :key="tech" + class="tech-badge" + > + {{ tech }} + </span> + <span v-if="techs.length > 5" class="tech-more"> + +{{ techs.length - 5 }} + </span> + </template>Alternatively, precompute in script:
const parsedTechs = computed(() => new Map(experiencesConfig.map(exp => [exp.slug, parseTechnologies(exp.technologies)])) );
382-394: Consider edge case for same-month durations.If an experience starts and ends in the same month,
monthswould be 0, resulting in "0mo" being displayed. Consider showing "< 1mo" or ensuring minimum display of "1mo" for better UX.- if (years === 0) return `${remainingMonths}mo`; + if (years === 0) return `${Math.max(1, remainingMonths)}mo`;
400-419: Consider using CSS variables for theme consistency.The hardcoded hex colors won't adapt to theme changes. The rest of the component uses CSS custom properties (
var(--color-*)), which would allow theme-aware styling.Either define these as CSS custom properties:
:root { --label-leadership: #8b5cf6; --label-architecture: #06b6d4; /* ... */ }Or accept the current approach if fixed brand colors are intentional for tag recognition across themes.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (1)
src/pages/ExperiencePage.vue(2 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.vue
📄 CodeRabbit inference engine (CLAUDE.md)
**/*.vue: Indent content inside<script>and<style>tags in Vue files (configured viavueIndentScriptAndStyle: truein.prettierrc)
Use Vue 3 Composition API with<script setup>syntax for Vue components
D3 integration should use imperative DOM manipulation within Vue lifecycle hooks, kept separate from Vue reactivity
Files:
src/pages/ExperiencePage.vue
**/*.{ts,tsx,vue}
📄 CodeRabbit inference engine (CLAUDE.md)
Use constants:
IDEAL_BLIP_WIDTH = 22px,NEW_GROUP_BLIP_WIDTH = 88px,EXISTING_GROUP_BLIP_WIDTH = 124px
Files:
src/pages/ExperiencePage.vue
🔇 Additional comments (5)
src/pages/ExperiencePage.vue (5)
2-21: LGTM!The header with view toggle is well-implemented. The toggle buttons use proper class bindings for the active state and direct state mutation is appropriate for this simple use case.
191-198: LGTM - Reactivity pattern is correct.Creating a new Set after mutation is necessary to trigger Vue's reactivity system. This is the correct approach for using
Setwith Vue 3'sref().
355-364: LGTM!The
getTotalMonthsfunction handles edge cases well withMath.max(1, months), ensuring at least 1 month is counted even for same-month start/end dates or potential data anomalies.
785-832: LGTM!The responsive design is well-structured with appropriate breakpoints. The kanban board gracefully degrades from horizontal scrolling columns to a vertical stack on medium screens, and the stats bar adapts to a grid layout on small screens.
107-116: LGTM!External link correctly uses
rel="noopener noreferrer"for security, and@click.stopprevents unintended card expansion when clicking the link.
| <article | ||
| v-for="(experience, index) in visibleExperiences" | ||
| :key="index" | ||
| class="experience-card" | ||
| v-for="exp in column.experiences" | ||
| :key="exp.slug" | ||
| class="kanban-card" | ||
| @click="toggleCard(exp.slug)" | ||
| > |
There was a problem hiding this comment.
Add keyboard accessibility for expandable cards.
The <article> elements are clickable but lack keyboard support. Users navigating with keyboard can't expand cards.
Add tabindex and keyboard handler:
<article
v-for="exp in column.experiences"
:key="exp.slug"
class="kanban-card"
+ tabindex="0"
+ role="button"
+ :aria-expanded="expandedCards.has(exp.slug)"
@click="toggleCard(exp.slug)"
+ @keydown.enter="toggleCard(exp.slug)"
+ @keydown.space.prevent="toggleCard(exp.slug)"
>🤖 Prompt for AI Agents
In src/pages/ExperiencePage.vue around lines 40 to 45, the clickable <article>
kanban cards are not keyboard-accessible; add keyboard support by giving each
article a tabindex="0" so it can receive focus, add role="button" and bind
aria-expanded to the card's expanded state, and add a keydown handler that
triggers the same toggleCard(exp.slug) on Enter and Space key presses (ensure
Space prevents page scroll). Ensure the existing @click remains unchanged so
mouse behavior is preserved.
| const decadeColumns = computed<Column[]>(() => { | ||
| const current = experiencesConfig.filter((exp) => !exp.endDate); | ||
| const twenties = experiencesConfig.filter((exp) => { | ||
| const year = new Date(exp.startDate).getFullYear(); | ||
| return year >= 2020 && exp.endDate; | ||
| }); | ||
| const tens = experiencesConfig.filter((exp) => { | ||
| const year = new Date(exp.startDate).getFullYear(); | ||
| return year >= 2010 && year < 2020; | ||
| }); | ||
| const older = experiencesConfig.filter((exp) => { | ||
| const year = new Date(exp.startDate).getFullYear(); | ||
| return year < 2010; | ||
| }); | ||
|
|
||
| return [ | ||
| { | ||
| id: "current", | ||
| title: "Current", | ||
| subtitle: "active positions", | ||
| icon: "🔥", | ||
| color: "var(--color-success)", | ||
| experiences: current, | ||
| }, | ||
| { | ||
| id: "2020s", | ||
| title: "2020s", | ||
| subtitle: "recent experience", | ||
| icon: "🚀", | ||
| color: "var(--color-teal)", | ||
| experiences: twenties, | ||
| }, | ||
| { | ||
| id: "2010s", | ||
| title: "2010s", | ||
| subtitle: "growth years", | ||
| icon: "📈", | ||
| color: "var(--color-orange)", | ||
| experiences: tens, | ||
| }, | ||
| { | ||
| id: "earlier", | ||
| title: "Pre-2010", | ||
| subtitle: "foundations", | ||
| icon: "🏛️", | ||
| color: "var(--color-purple)", | ||
| experiences: older, | ||
| }, | ||
| ].filter((col) => col.experiences.length > 0); | ||
| }); |
There was a problem hiding this comment.
Experiences may appear in multiple decade columns.
The filtering logic has overlapping conditions. An experience that started before 2020 but has no endDate will appear in both the "Current" column (line 211: !exp.endDate) and its decade column (lines 216-222 don't exclude ongoing roles).
For example, a position started in 2018 with no end date appears in both "Current" and "2010s".
Add exp.endDate checks to exclude current positions from decade columns:
const twenties = experiencesConfig.filter((exp) => {
const year = new Date(exp.startDate).getFullYear();
- return year >= 2020 && exp.endDate;
+ return year >= 2020 && !!exp.endDate;
});
const tens = experiencesConfig.filter((exp) => {
const year = new Date(exp.startDate).getFullYear();
- return year >= 2010 && year < 2020;
+ return year >= 2010 && year < 2020 && !!exp.endDate;
});
const older = experiencesConfig.filter((exp) => {
const year = new Date(exp.startDate).getFullYear();
- return year < 2010;
+ return year < 2010 && !!exp.endDate;
});📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const decadeColumns = computed<Column[]>(() => { | |
| const current = experiencesConfig.filter((exp) => !exp.endDate); | |
| const twenties = experiencesConfig.filter((exp) => { | |
| const year = new Date(exp.startDate).getFullYear(); | |
| return year >= 2020 && exp.endDate; | |
| }); | |
| const tens = experiencesConfig.filter((exp) => { | |
| const year = new Date(exp.startDate).getFullYear(); | |
| return year >= 2010 && year < 2020; | |
| }); | |
| const older = experiencesConfig.filter((exp) => { | |
| const year = new Date(exp.startDate).getFullYear(); | |
| return year < 2010; | |
| }); | |
| return [ | |
| { | |
| id: "current", | |
| title: "Current", | |
| subtitle: "active positions", | |
| icon: "🔥", | |
| color: "var(--color-success)", | |
| experiences: current, | |
| }, | |
| { | |
| id: "2020s", | |
| title: "2020s", | |
| subtitle: "recent experience", | |
| icon: "🚀", | |
| color: "var(--color-teal)", | |
| experiences: twenties, | |
| }, | |
| { | |
| id: "2010s", | |
| title: "2010s", | |
| subtitle: "growth years", | |
| icon: "📈", | |
| color: "var(--color-orange)", | |
| experiences: tens, | |
| }, | |
| { | |
| id: "earlier", | |
| title: "Pre-2010", | |
| subtitle: "foundations", | |
| icon: "🏛️", | |
| color: "var(--color-purple)", | |
| experiences: older, | |
| }, | |
| ].filter((col) => col.experiences.length > 0); | |
| }); | |
| const decadeColumns = computed<Column[]>(() => { | |
| const current = experiencesConfig.filter((exp) => !exp.endDate); | |
| const twenties = experiencesConfig.filter((exp) => { | |
| const year = new Date(exp.startDate).getFullYear(); | |
| return year >= 2020 && !!exp.endDate; | |
| }); | |
| const tens = experiencesConfig.filter((exp) => { | |
| const year = new Date(exp.startDate).getFullYear(); | |
| return year >= 2010 && year < 2020 && !!exp.endDate; | |
| }); | |
| const older = experiencesConfig.filter((exp) => { | |
| const year = new Date(exp.startDate).getFullYear(); | |
| return year < 2010 && !!exp.endDate; | |
| }); | |
| return [ | |
| { | |
| id: "current", | |
| title: "Current", | |
| subtitle: "active positions", | |
| icon: "🔥", | |
| color: "var(--color-success)", | |
| experiences: current, | |
| }, | |
| { | |
| id: "2020s", | |
| title: "2020s", | |
| subtitle: "recent experience", | |
| icon: "🚀", | |
| color: "var(--color-teal)", | |
| experiences: twenties, | |
| }, | |
| { | |
| id: "2010s", | |
| title: "2010s", | |
| subtitle: "growth years", | |
| icon: "📈", | |
| color: "var(--color-orange)", | |
| experiences: tens, | |
| }, | |
| { | |
| id: "earlier", | |
| title: "Pre-2010", | |
| subtitle: "foundations", | |
| icon: "🏛️", | |
| color: "var(--color-purple)", | |
| experiences: older, | |
| }, | |
| ].filter((col) => col.experiences.length > 0); | |
| }); |
🤖 Prompt for AI Agents
In src/pages/ExperiencePage.vue around lines 210 to 259, the decade column
filters allow ongoing experiences (no endDate) to appear in both the "Current"
column and their decade column; update the decade filters (twenties, tens,
older) to explicitly require exp.endDate (truthy) so ongoing roles are excluded
from decade buckets, i.e. add an exp.endDate check alongside the existing
startDate year checks in each filter.
| const roleColumns = computed<Column[]>(() => { | ||
| const leadership = experiencesConfig.filter((exp) => | ||
| exp.tags.some((t) => | ||
| ["Leadership", "Management", "Director", "Head"].some((r) => | ||
| t.toLowerCase().includes(r.toLowerCase()) | ||
| ) | ||
| ) | ||
| ); | ||
| const architecture = experiencesConfig.filter((exp) => | ||
| exp.tags.some((t) => | ||
| ["Architecture", "Principal", "Staff"].some((r) => | ||
| t.toLowerCase().includes(r.toLowerCase()) | ||
| ) | ||
| ) | ||
| ); | ||
| const engineering = experiencesConfig.filter( | ||
| (exp) => | ||
| !leadership.includes(exp) && | ||
| !architecture.includes(exp) && | ||
| exp.tags.some((t) => | ||
| ["Engineering", "Developer", "Software"].some((r) => | ||
| t.toLowerCase().includes(r.toLowerCase()) | ||
| ) | ||
| ) | ||
| ); | ||
| const other = experiencesConfig.filter( | ||
| (exp) => | ||
| !leadership.includes(exp) && | ||
| !architecture.includes(exp) && | ||
| !engineering.includes(exp) | ||
| ); |
There was a problem hiding this comment.
Experiences with mixed tags appear in multiple role columns.
A role with tags like "Principal Engineering Manager" would match both "Leadership" (contains "Manager") and "Architecture" (contains "Principal"), causing it to appear in multiple columns.
Unlike the engineering and other filters (lines 276-291) which exclude prior matches, leadership and architecture filters don't exclude each other.
Apply mutual exclusion to architecture filter:
const architecture = experiencesConfig.filter((exp) =>
+ !leadership.includes(exp) &&
exp.tags.some((t) =>
["Architecture", "Principal", "Staff"].some((r) =>
t.toLowerCase().includes(r.toLowerCase())
)
)
);🤖 Prompt for AI Agents
In src/pages/ExperiencePage.vue around lines 261 to 291, the architecture filter
can include experiences already matched by the leadership filter causing
duplicate entries (e.g., "Principal Engineering Manager"); update the
architecture filter to exclude items present in leadership (mirror the
mutual-exclusion logic used by the engineering filter) so that architecture only
includes experiences that are not in leadership and not in engineering/other, by
adding a check like !leadership.includes(exp) to the architecture filter
predicate.
Replace the vertical timeline layout with a kanban-style board to present experience entries as columns and cards, enabling grouping by decade or role and interactive card expansion. This change was requested by the prompt "what about a kanban style?" and reorganizes the template, script, and styles to:
Summary by CodeRabbit
Release Notes
New Features
Improvements
✏️ Tip: You can customize this high-level summary in your review settings.