Skip to content

feat(xp): convert experience timeline to kanban board#34

Open
caiokf wants to merge 1 commit intomainfrom
kanban-experience-board
Open

feat(xp): convert experience timeline to kanban board#34
caiokf wants to merge 1 commit intomainfrom
kanban-experience-board

Conversation

@caiokf
Copy link
Owner

@caiokf caiokf commented Dec 14, 2025

kanban

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.

Summary by CodeRabbit

Release Notes

  • New Features

    • Redesigned experience page with Kanban-style board layout
    • Added toggle to group experiences by decade or role
    • Cards now expand to reveal detailed highlights and technologies
    • Added statistics block displaying total positions, companies, years, and technologies
  • Improvements

    • Streamlined card layout with tags, duration badges, and company information
    • Enhanced visual styling and responsive design

✏️ Tip: You can customize this high-level summary in your review settings.

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.
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Dec 14, 2025

Walkthrough

The 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

Cohort / File(s) Summary
Kanban Experience Page Redesign
src/pages/ExperiencePage.vue
Replaced timeline layout with Kanban-board structure; introduced dual grouping modes (by decade: current, 2020s, 2010s, Pre-2010; by role: Leadership, Architecture, Engineering, Other); added card expansion state management; redesigned card visuals with tag chips, duration badges, and compact headers; implemented new helper functions (formatDateRange, calculateDuration, getTotalMonths, getLabelColor, parseTechnologies, getCompanyLogo); added kanban-stats block displaying total positions, companies, years, and technologies; removed BadgeGroup import and previous timeline/sidebar structure; updated styling for Kanban layout and responsive behavior.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Template restructuring: Verify the new Kanban component hierarchy, grouping logic, and card rendering correctness across both decade and role views.
  • State management: Review the expandedCards Set, groupBy toggle, and the logic for toggling card expansion to ensure proper reactivity.
  • Helper functions and computations: Examine calculateDuration, formatDateRange, getTotalMonths, getLabelColor, and the dynamic column generation (decadeColumns/roleColumns) for accuracy in date handling, tag-to-color mapping, and grouping logic.
  • Styling and layout: Validate the Kanban layout CSS for proper column and card styling, responsive breakpoints, and visual consistency.
  • Logo and tag handling: Check the dynamic company logo loading (getCompanyLogo) and tag parsing (parseTechnologies) for edge cases and fallback behavior.

Poem

🐰 A Kanban board now greets the eye, where decades dance and roles align,
Cards expand like burrows deep to show the treasures stored inside,
No longer bound by time's straight line, but grouped in columns proud and fine,
Statistics bloom in gleaming stats—a hutch of skills both broad and wide! 🌟

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and accurately describes the main change: converting the experience timeline layout to a kanban board format.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch kanban-experience-board

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, months would 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

📥 Commits

Reviewing files that changed from the base of the PR and between 9570d4e and 5b21851.

📒 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 via vueIndentScriptAndStyle: true in .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 Set with Vue 3's ref().


355-364: LGTM!

The getTotalMonths function handles edge cases well with Math.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.stop prevents unintended card expansion when clicking the link.

Comment on lines 40 to 45
<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)"
>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +210 to +259
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);
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Suggested change
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.

Comment on lines +261 to +291
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)
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant