feat(xp): transform experiences page into github-style profile#33
feat(xp): transform experiences page into github-style profile#33
Conversation
Implement a GitHub profile inspired redesign for the experiences page to present work history as a developer profile. The page now includes a left sidebar with avatar, bio, stats and top technologies, and a main area with README-like intro, a contribution heatmap, pinned “repositories” (featured experiences), and a searchable list of experience repos. This change was requested after exploring multiple visual options (timeline, git-log, editor, terminal) and the user selected the GitHub profile style to be implemented.
WalkthroughExperiencePage.vue was restructured from an experience-timeline layout to a GitHub-profile-style interface. The redesign introduces a left sidebar with profile info, a contributions graph, pinned repositories section, and a searchable repositories list with new computed properties and utility helpers. Changes
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Areas requiring extra attention:
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 (2)
src/pages/ExperiencePage.vue (2)
292-293: Comment doesn't match implementation.The comment says "most recent 6" but the code takes the first 6 items from
experiencesConfig. This is only accurate ifexperiencesConfigis pre-sorted by date (most recent first). If the data ordering changes, the pinned section won't show the most recent experiences.Consider either updating the comment to reflect the actual behavior, or explicitly sorting:
- // Pinned experiences (most recent 6) - const pinnedExperiences = computed(() => experiencesConfig.slice(0, 6)); + // Pinned experiences (first 6 from config - assumes pre-sorted by date) + const pinnedExperiences = computed(() => experiencesConfig.slice(0, 6));
384-460: Consider extracting utility functions.The formatting functions (
formatRepoName,formatDateRange,formatRelativeDate,calculateMonths,truncateHighlight) andgetTechColorcolor mapping are general-purpose utilities that could be reused elsewhere. Consider extracting them to a shared utilities file:// src/utils/formatters.ts export function formatRepoName(company: string): string { ... } export function formatDateRange(exp: Experience): string { ... } // etc.This would reduce the component's size and improve testability.
📜 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 (1)
src/pages/ExperiencePage.vue (1)
262-269: Good use of Vue 3 Composition API.The component correctly uses
<script setup>syntax with proper imports and reactive primitives (ref,computed). The code organization separates stats calculations, filtered data, and utility functions cleanly.
| <div class="graph-grid"> | ||
| <div | ||
| v-for="(month, index) in contributionData" | ||
| :key="index" | ||
| class="contribution-cell" | ||
| :class="getCellClass(month.level)" | ||
| :title="`${month.label}: ${month.projects} projects`" | ||
| ></div> | ||
| </div> |
There was a problem hiding this comment.
Contribution grid layout mismatch.
The grid is configured with grid-template-columns: repeat(132, 10px) and grid-template-rows: repeat(7, 10px) with grid-auto-flow: column, but contributionData generates 132 items (11 years × 12 months). With 7 rows and column auto-flow, you'd need 132 columns only if you had 132×7=924 items.
The current setup will place 132 items into 7 rows × ~19 columns, leaving most of the 132-column grid empty and creating a misaligned display.
Consider either:
- Generate data per-week (52 weeks × 11 years × 7 days ≈ cells) to match GitHub's actual contribution graph, or
- Adjust the grid to match your month-based data structure (e.g., 12 columns × 11 rows).
🤖 Prompt for AI Agents
In src/pages/ExperiencePage.vue around lines 109 to 117, the CSS grid is sized
for 132 columns × 7 rows but contributionData produces 132 month items (11 years
× 12 months), so cells are placed incorrectly; fix by matching the grid to the
month-based data: change the grid layout to 12 columns × 11 rows (e.g.,
grid-template-columns: repeat(12, 10px); grid-template-rows: repeat(11, 10px);
and remove/adjust grid-auto-flow) or alternatively change the data generation to
a week/day granularity to match the original 132×7 layout — pick one approach
and update the grid CSS and/or data generation accordingly so the number of grid
cells equals the number of rendered items.
| const filteredExperiences = computed(() => { | ||
| if (!searchQuery.value) { | ||
| return visibleExperiences.value; | ||
| } | ||
| const query = searchQuery.value.toLowerCase(); | ||
| return visibleExperiences.value.filter( | ||
| (exp) => | ||
| exp.company.toLowerCase().includes(query) || | ||
| exp.position.toLowerCase().includes(query) || | ||
| exp.technologies.toLowerCase().includes(query) | ||
| ); | ||
| }); |
There was a problem hiding this comment.
Search only filters visible experiences, not all.
filteredExperiences filters visibleExperiences, which is limited to INITIAL_VISIBLE_COUNT (6) when showAll is false. Users searching for an experience beyond the first 6 will get no results until they click "Show more."
Consider searching all experiences and then applying visibility limit, or auto-expand when a search query is entered:
const filteredExperiences = computed(() => {
if (!searchQuery.value) {
return visibleExperiences.value;
}
const query = searchQuery.value.toLowerCase();
- return visibleExperiences.value.filter(
+ return experiencesConfig.filter(
(exp) =>
exp.company.toLowerCase().includes(query) ||
exp.position.toLowerCase().includes(query) ||
exp.technologies.toLowerCase().includes(query)
);
});📝 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 filteredExperiences = computed(() => { | |
| if (!searchQuery.value) { | |
| return visibleExperiences.value; | |
| } | |
| const query = searchQuery.value.toLowerCase(); | |
| return visibleExperiences.value.filter( | |
| (exp) => | |
| exp.company.toLowerCase().includes(query) || | |
| exp.position.toLowerCase().includes(query) || | |
| exp.technologies.toLowerCase().includes(query) | |
| ); | |
| }); | |
| const filteredExperiences = computed(() => { | |
| if (!searchQuery.value) { | |
| return visibleExperiences.value; | |
| } | |
| const query = searchQuery.value.toLowerCase(); | |
| return experiencesConfig.filter( | |
| (exp) => | |
| exp.company.toLowerCase().includes(query) || | |
| exp.position.toLowerCase().includes(query) || | |
| exp.technologies.toLowerCase().includes(query) | |
| ); | |
| }); |
🤖 Prompt for AI Agents
In src/pages/ExperiencePage.vue around lines 304-315, filteredExperiences
currently searches only visibleExperiences (limited by INITIAL_VISIBLE_COUNT),
causing misses for items beyond the first page; change the logic to perform the
search over the full experiences list (e.g., allExperiences or
experiences.value) and then, if showAll is false and there is no search query,
apply the visibility limit, or alternatively always search all experiences and
when a non-empty searchQuery is present either return the full matching set
(ignoring the INITIAL_VISIBLE_COUNT) or programmatically set showAll=true;
implement one of these behaviors consistently and ensure the computed still
respects case-insensitive matching and uses the same fields (company, position,
technologies).
| function generateMiniGraph(exp: Experience): string { | ||
| const points: string[] = []; | ||
| const width = 155; | ||
| const height = 30; | ||
|
|
||
| // Generate a simple activity pattern | ||
| for (let i = 0; i <= 10; i++) { | ||
| const x = (i / 10) * width; | ||
| const seed = exp.slug.charCodeAt(i % exp.slug.length) || 50; | ||
| const variation = Math.sin(i + seed) * 8; | ||
| const y = height / 2 + variation; | ||
| points.push(`${i === 0 ? "M" : "L"}${x},${y}`); | ||
| } | ||
|
|
||
| return points.join(" "); | ||
| } |
There was a problem hiding this comment.
Potential error if exp.slug is empty.
Line 470 uses exp.slug.charCodeAt(i % exp.slug.length). If slug is an empty string, exp.slug.length is 0, causing division by zero (i % 0 returns NaN), and charCodeAt(NaN) returns NaN, not triggering the || 50 fallback.
Add a guard for empty slugs:
function generateMiniGraph(exp: Experience): string {
const points: string[] = [];
const width = 155;
const height = 30;
+ const slugSeed = exp.slug || "default";
// Generate a simple activity pattern
for (let i = 0; i <= 10; i++) {
const x = (i / 10) * width;
- const seed = exp.slug.charCodeAt(i % exp.slug.length) || 50;
+ const seed = slugSeed.charCodeAt(i % slugSeed.length) || 50;
const variation = Math.sin(i + seed) * 8;
const y = height / 2 + variation;
points.push(`${i === 0 ? "M" : "L"}${x},${y}`);
}
return points.join(" ");
}🤖 Prompt for AI Agents
In src/pages/ExperiencePage.vue around lines 462 to 477, the generateMiniGraph
function can divide by zero when exp.slug is an empty string because i %
exp.slug.length becomes NaN; change the seed calculation to guard against an
empty slug by precomputing const len = exp.slug.length || 0 and then using const
seed = len > 0 ? exp.slug.charCodeAt(i % len) : 50 (or similar) so the index
operation never uses 0 and a numeric fallback (50) is always used; ensure seed
is coerced to a number before using it in Math.sin.
Summary by CodeRabbit
Release Notes
New Features
Style
✏️ Tip: You can customize this high-level summary in your review settings.