diff --git a/packages/shared/src/components/MultiSourceHeatmap/ActivityOverviewCard.tsx b/packages/shared/src/components/MultiSourceHeatmap/ActivityOverviewCard.tsx new file mode 100644 index 0000000000..b0da3a4e23 --- /dev/null +++ b/packages/shared/src/components/MultiSourceHeatmap/ActivityOverviewCard.tsx @@ -0,0 +1,195 @@ +import type { ReactElement } from 'react'; +import React, { useState, useMemo } from 'react'; +import classNames from 'classnames'; +import { subDays } from 'date-fns'; +import { + Typography, + TypographyType, + TypographyColor, + TypographyTag, +} from '../typography/Typography'; +import { ArrowIcon, CalendarIcon } from '../icons'; +import { IconSize } from '../Icon'; +import { MultiSourceHeatmap } from './MultiSourceHeatmap'; +import type { DayActivityDetailed, ActivitySource } from './types'; +import { SOURCE_CONFIGS, generateMockMultiSourceActivity } from './types'; + +type TimeRange = '3m' | '6m' | '1y'; + +interface ActivityOverviewCardProps { + activities?: DayActivityDetailed[]; + initialTimeRange?: TimeRange; + initiallyOpen?: boolean; +} + +export function ActivityOverviewCard({ + activities: propActivities, + initialTimeRange = '1y', + initiallyOpen = false, +}: ActivityOverviewCardProps): ReactElement { + const [isOpen, setIsOpen] = useState(initiallyOpen); + const [timeRange, setTimeRange] = useState(initialTimeRange); + + // Memoize dates to prevent unnecessary recalculations + const { startDate, endDate } = useMemo(() => { + const end = new Date(); + let start: Date; + switch (timeRange) { + case '3m': + start = subDays(end, 90); + break; + case '6m': + start = subDays(end, 180); + break; + case '1y': + default: + start = subDays(end, 365); + } + return { startDate: start, endDate: end }; + }, [timeRange]); + + // Use provided activities or generate mock + const activities = useMemo(() => { + if (propActivities) { + return propActivities.filter((a) => { + const date = new Date(a.date); + return date >= startDate && date <= endDate; + }); + } + return generateMockMultiSourceActivity(startDate, endDate); + }, [propActivities, startDate, endDate]); + + // Calculate quick stats for header + const quickStats = useMemo(() => { + const total = activities.reduce((sum, a) => sum + a.total, 0); + const activeDays = activities.filter((a) => a.total > 0).length; + + // Get top sources + const sourceTotals: Partial> = {}; + activities.forEach((a) => { + Object.entries(a.sources).forEach(([source, count]) => { + sourceTotals[source as ActivitySource] = + (sourceTotals[source as ActivitySource] || 0) + count; + }); + }); + + const topSources = Object.entries(sourceTotals) + .sort((a, b) => b[1] - a[1]) + .slice(0, 3) + .map(([source]) => source as ActivitySource); + + return { total, activeDays, topSources }; + }, [activities]); + + return ( +
+ {/* Header */} + + + {/* Collapsible content */} +
+
+ {/* Time range selector */} +
+ + Period + +
+ {(['3m', '6m', '1y'] as TimeRange[]).map((range) => ( + + ))} +
+
+ + {/* Heatmap */} + +
+
+
+ ); +} diff --git a/packages/shared/src/components/MultiSourceHeatmap/MultiSourceHeatmap.tsx b/packages/shared/src/components/MultiSourceHeatmap/MultiSourceHeatmap.tsx new file mode 100644 index 0000000000..8a930f1d57 --- /dev/null +++ b/packages/shared/src/components/MultiSourceHeatmap/MultiSourceHeatmap.tsx @@ -0,0 +1,513 @@ +import type { ReactElement, ReactNode } from 'react'; +import React, { useMemo, useState } from 'react'; +import { addDays, differenceInDays, endOfWeek, subDays } from 'date-fns'; +import classNames from 'classnames'; +import { Tooltip } from '../tooltip/Tooltip'; +import { + Typography, + TypographyType, + TypographyColor, +} from '../typography/Typography'; +import { GitHubIcon, GitLabIcon } from '../icons'; +import { IconSize } from '../Icon'; +import type { ActivitySource, DayActivityDetailed } from './types'; +import { SOURCE_CONFIGS } from './types'; + +const DAYS_IN_WEEK = 7; +const SQUARE_SIZE = 10; +const GUTTER_SIZE = 3; +const SQUARE_SIZE_WITH_GUTTER = SQUARE_SIZE + GUTTER_SIZE; +const MONTH_LABELS = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +]; + +// Intensity levels for the heatmap +const INTENSITY_COLORS = [ + 'var(--theme-float)', // 0 - no activity + 'var(--theme-overlay-quaternary-cabbage)', // 1 - low + 'var(--theme-accent-cabbage-subtler)', // 2 - medium-low + 'var(--theme-accent-cabbage-default)', // 3 - medium + 'var(--theme-accent-onion-default)', // 4 - high +]; + +// Multi-source gradient colors for pie segments +const getSourceColor = (source: ActivitySource): string => { + return SOURCE_CONFIGS[source]?.color || '#666'; +}; + +interface SourceIconProps { + source: ActivitySource; + size?: IconSize; + className?: string; +} + +function SourceIcon({ + source, + size = IconSize.XSmall, + className, +}: SourceIconProps): ReactElement | null { + switch (source) { + case 'github': + return ; + case 'gitlab': + return ; + default: + return ( +
+ ); + } +} + +interface DayTooltipProps { + activity: DayActivityDetailed | undefined; + date: Date; +} + +function DayTooltip({ activity, date }: DayTooltipProps): ReactElement { + const dateStr = date.toLocaleDateString('en-US', { + weekday: 'long', + month: 'short', + day: 'numeric', + year: 'numeric', + }); + + if (!activity || activity.total === 0) { + return ( +
+ {dateStr} + No activity +
+ ); + } + + return ( +
+ {dateStr} +
+ + {activity.total} + + contributions +
+
+ {Object.entries(activity.sources).map(([source, count]) => ( +
+
+ + + {SOURCE_CONFIGS[source as ActivitySource]?.label} + +
+ {count} +
+ ))} +
+ {activity.breakdown && ( +
+ {activity.breakdown.commits > 0 && ( + {activity.breakdown.commits} commits + )} + {activity.breakdown.pullRequests > 0 && ( + {activity.breakdown.pullRequests} PRs + )} + {activity.breakdown.reads > 0 && ( + {activity.breakdown.reads} reads + )} + {activity.breakdown.answers > 0 && ( + {activity.breakdown.answers} answers + )} +
+ )} +
+ ); +} + +interface MultiSourceHeatmapProps { + activities: DayActivityDetailed[]; + startDate?: Date; + endDate?: Date; + enabledSources?: ActivitySource[]; + showLegend?: boolean; + showStats?: boolean; +} + +export function MultiSourceHeatmap({ + activities, + startDate: propStartDate, + endDate: propEndDate, + enabledSources, + showLegend = true, + showStats = true, +}: MultiSourceHeatmapProps): ReactElement { + const endDate = propEndDate || new Date(); + const startDate = propStartDate || subDays(endDate, 365); + + const [hoveredSource, setHoveredSource] = useState( + null, + ); + + // Build activity map by date + const activityMap = useMemo(() => { + const map: Record = {}; + activities.forEach((activity) => { + map[activity.date] = activity; + }); + return map; + }, [activities]); + + // Calculate totals and stats + const stats = useMemo(() => { + const sourceTotals: Partial> = {}; + let totalContributions = 0; + let activeDays = 0; + let currentStreak = 0; + let longestStreak = 0; + let tempStreak = 0; + + // Sort activities by date for streak calculation + const sortedActivities = [...activities].sort( + (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(), + ); + + sortedActivities.forEach((activity) => { + if (activity.total > 0) { + totalContributions += activity.total; + activeDays += 1; + tempStreak += 1; + longestStreak = Math.max(longestStreak, tempStreak); + + Object.entries(activity.sources).forEach(([source, count]) => { + sourceTotals[source as ActivitySource] = + (sourceTotals[source as ActivitySource] || 0) + count; + }); + } else { + tempStreak = 0; + } + }); + + // Calculate current streak (from most recent day) + for (let i = sortedActivities.length - 1; i >= 0; i -= 1) { + if (sortedActivities[i].total > 0) { + currentStreak += 1; + } else { + break; + } + } + + return { + totalContributions, + activeDays, + currentStreak, + longestStreak, + sourceTotals, + }; + }, [activities]); + + // Get active sources + const activeSources = useMemo(() => { + const sources = Object.keys(stats.sourceTotals) as ActivitySource[]; + if (enabledSources) { + return sources.filter((s) => enabledSources.includes(s)); + } + return sources; + }, [stats.sourceTotals, enabledSources]); + + // Calculate week data + const numEmptyDaysAtEnd = DAYS_IN_WEEK - 1 - endDate.getDay(); + const numEmptyDaysAtStart = startDate.getDay(); + const startDateWithEmptyDays = addDays(startDate, -numEmptyDaysAtStart); + const dateDifferenceInDays = differenceInDays(endDate, startDate); + const numDaysRoundedToWeek = + dateDifferenceInDays + numEmptyDaysAtStart + numEmptyDaysAtEnd; + const weekCount = Math.ceil(numDaysRoundedToWeek / DAYS_IN_WEEK); + + // Get intensity level (0-4) based on activity + const getIntensityLevel = (total: number): number => { + if (total === 0) { + return 0; + } + if (total <= 3) { + return 1; + } + if (total <= 8) { + return 2; + } + if (total <= 15) { + return 3; + } + return 4; + }; + + // Render a single day square + const renderDay = (weekIndex: number, dayIndex: number): ReactNode => { + const index = weekIndex * DAYS_IN_WEEK + dayIndex; + const isOutOfRange = + index < numEmptyDaysAtStart || + index >= numEmptyDaysAtStart + dateDifferenceInDays + 1; + + if (isOutOfRange) { + return null; + } + + const date = addDays(startDateWithEmptyDays, index); + const dateStr = date.toISOString().split('T')[0]; + const activity = activityMap[dateStr]; + const total = activity?.total || 0; + const intensity = getIntensityLevel(total); + + // Check if any source is hovered and this day has that source + const isHighlighted = + !hoveredSource || + (activity?.sources && hoveredSource in activity.sources); + const opacity = hoveredSource && !isHighlighted ? 0.2 : 1; + + // For multi-source days, create a gradient or show dominant source + const sources = activity?.sources + ? (Object.keys(activity.sources) as ActivitySource[]) + : []; + const hasMultipleSources = sources.length > 1; + + return ( + } + delayDuration={100} + > + + {/* Base square */} + + {/* Multi-source indicator - small colored dots */} + {hasMultipleSources && total > 0 && ( + + {sources.slice(0, 3).map((source, i) => ( + + ))} + + )} + {/* Single source color accent */} + {sources.length === 1 && total > 0 && ( + + )} + + + ); + }; + + // Render a week column + const renderWeek = (weekIndex: number): ReactNode => { + return ( + + {Array.from({ length: DAYS_IN_WEEK }, (_, dayIndex) => + renderDay(weekIndex, dayIndex), + )} + + ); + }; + + // Render month labels + const renderMonthLabels = (): ReactNode => { + const labels: ReactNode[] = []; + + for (let weekIndex = 0; weekIndex < weekCount; weekIndex += 1) { + const date = endOfWeek( + addDays(startDateWithEmptyDays, weekIndex * DAYS_IN_WEEK), + ); + if (date.getDate() >= 7 && date.getDate() <= 14) { + labels.push( + + {MONTH_LABELS[date.getMonth()]} + , + ); + } + } + + return labels; + }; + + const svgWidth = weekCount * SQUARE_SIZE_WITH_GUTTER; + const svgHeight = DAYS_IN_WEEK * SQUARE_SIZE_WITH_GUTTER + 20; + + return ( +
+ {/* Stats row */} + {showStats && ( +
+
+ + {stats.totalContributions.toLocaleString()} + + + contributions + +
+
+ + {stats.activeDays} + + + active days + +
+
+ + {stats.currentStreak} + + + day streak + +
+
+ + {stats.longestStreak} + + + longest streak + +
+
+ )} + + {/* Heatmap */} +
+ + {/* Month labels */} + {renderMonthLabels()} + {/* Weeks grid */} + + {Array.from({ length: weekCount }, (_, weekIndex) => + renderWeek(weekIndex), + )} + + +
+ + {/* Legend and source filters */} + {showLegend && ( +
+ {/* Intensity legend */} +
+ + Less + + {INTENSITY_COLORS.map((color) => ( +
+ ))} + + More + +
+ + {/* Source breakdown */} +
+ {activeSources.map((source) => ( + + ))} +
+
+ )} +
+ ); +} diff --git a/packages/shared/src/components/MultiSourceHeatmap/index.ts b/packages/shared/src/components/MultiSourceHeatmap/index.ts new file mode 100644 index 0000000000..45623929a7 --- /dev/null +++ b/packages/shared/src/components/MultiSourceHeatmap/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export { MultiSourceHeatmap } from './MultiSourceHeatmap'; +export { ActivityOverviewCard } from './ActivityOverviewCard'; diff --git a/packages/shared/src/components/MultiSourceHeatmap/types.ts b/packages/shared/src/components/MultiSourceHeatmap/types.ts new file mode 100644 index 0000000000..d96e188f52 --- /dev/null +++ b/packages/shared/src/components/MultiSourceHeatmap/types.ts @@ -0,0 +1,170 @@ +/** + * Types for multi-source activity heatmap + * Aggregates contributions from various platforms + */ + +export type ActivitySource = + | 'github' + | 'gitlab' + | 'dailydev' + | 'stackoverflow' + | 'linkedin' + | 'devto'; + +export interface SourceConfig { + id: ActivitySource; + label: string; + color: string; + icon: string; + enabled: boolean; +} + +export interface DayActivity { + date: string; + sources: Partial>; + total: number; +} + +export interface ActivityBreakdown { + commits: number; + pullRequests: number; + reviews: number; + issues: number; + posts: number; + comments: number; + reads: number; + upvotes: number; + answers: number; +} + +export interface DayActivityDetailed extends DayActivity { + breakdown?: Partial; +} + +export const SOURCE_CONFIGS: Record< + ActivitySource, + Omit +> = { + github: { + id: 'github', + label: 'GitHub', + color: '#238636', + icon: 'GitHub', + }, + gitlab: { + id: 'gitlab', + label: 'GitLab', + color: '#fc6d26', + icon: 'GitLab', + }, + dailydev: { + id: 'dailydev', + label: 'daily.dev', + color: '#ce3df3', + icon: 'Daily', + }, + stackoverflow: { + id: 'stackoverflow', + label: 'Stack Overflow', + color: '#f48024', + icon: 'StackOverflow', + }, + linkedin: { + id: 'linkedin', + label: 'LinkedIn', + color: '#0a66c2', + icon: 'LinkedIn', + }, + devto: { + id: 'devto', + label: 'DEV.to', + color: '#3b49df', + icon: 'DevTo', + }, +}; + +// Generate mock activity data +export const generateMockMultiSourceActivity = ( + startDate: Date, + endDate: Date, +): DayActivityDetailed[] => { + const activities: DayActivityDetailed[] = []; + const currentDate = new Date(startDate); + + while (currentDate <= endDate) { + const dateStr = currentDate.toISOString().split('T')[0]; + const dayOfWeek = currentDate.getDay(); + const isWeekend = dayOfWeek === 0 || dayOfWeek === 6; + + // Simulate realistic activity patterns + const baseActivity = isWeekend ? 0.3 : 1; + const randomFactor = Math.random(); + + // Some days have no activity + if (randomFactor < 0.15) { + activities.push({ + date: dateStr, + sources: {}, + total: 0, + }); + } else { + const sources: Partial> = {}; + let total = 0; + + // GitHub - most active + if (randomFactor > 0.2) { + const github = Math.floor(Math.random() * 15 * baseActivity) + 1; + sources.github = github; + total += github; + } + + // daily.dev - reading activity + if (randomFactor > 0.1) { + const dailydev = Math.floor(Math.random() * 8 * baseActivity) + 1; + sources.dailydev = dailydev; + total += dailydev; + } + + // GitLab - occasional + if (randomFactor > 0.6) { + const gitlab = Math.floor(Math.random() * 6 * baseActivity) + 1; + sources.gitlab = gitlab; + total += gitlab; + } + + // Stack Overflow - rare but valuable + if (randomFactor > 0.85) { + const stackoverflow = Math.floor(Math.random() * 3) + 1; + sources.stackoverflow = stackoverflow; + total += stackoverflow; + } + + // DEV.to - occasional posts/comments + if (randomFactor > 0.9) { + const devto = Math.floor(Math.random() * 2) + 1; + sources.devto = devto; + total += devto; + } + + activities.push({ + date: dateStr, + sources, + total, + breakdown: { + commits: sources.github ? Math.floor(sources.github * 0.6) : 0, + pullRequests: sources.github ? Math.floor(sources.github * 0.2) : 0, + reviews: sources.github ? Math.floor(sources.github * 0.2) : 0, + posts: (sources.dailydev || 0) + (sources.devto || 0), + reads: sources.dailydev ? sources.dailydev * 3 : 0, + comments: Math.floor(total * 0.1), + upvotes: Math.floor(total * 0.15), + answers: sources.stackoverflow || 0, + }, + }); + } + + currentDate.setDate(currentDate.getDate() + 1); + } + + return activities; +}; diff --git a/packages/shared/src/features/profile/components/experience/experience-posts/ExperiencePostItem.tsx b/packages/shared/src/features/profile/components/experience/experience-posts/ExperiencePostItem.tsx new file mode 100644 index 0000000000..9375ffe3b5 --- /dev/null +++ b/packages/shared/src/features/profile/components/experience/experience-posts/ExperiencePostItem.tsx @@ -0,0 +1,310 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyType, + TypographyColor, +} from '../../../../../components/typography/Typography'; +import { + FlagIcon, + LinkIcon, + TerminalIcon, + PlayIcon, + StarIcon, + GitHubIcon, + OpenLinkIcon, +} from '../../../../../components/icons'; +import { IconSize } from '../../../../../components/Icon'; +import { Pill, PillSize } from '../../../../../components/Pill'; +import { Image, ImageType } from '../../../../../components/image/Image'; +import type { + ExperiencePost, + ProjectPost, + PublicationPost, + MediaPost, + AchievementPost, + OpenSourcePost, +} from './types'; +import { ExperiencePostType } from './types'; + +const TYPE_ICONS = { + [ExperiencePostType.Milestone]: FlagIcon, + [ExperiencePostType.Publication]: LinkIcon, + [ExperiencePostType.Project]: TerminalIcon, + [ExperiencePostType.Media]: PlayIcon, + [ExperiencePostType.Achievement]: StarIcon, + [ExperiencePostType.OpenSource]: GitHubIcon, +}; + +const TYPE_COLORS = { + [ExperiencePostType.Milestone]: + 'bg-accent-onion-subtler text-accent-onion-default', + [ExperiencePostType.Publication]: + 'bg-accent-water-subtler text-accent-water-default', + [ExperiencePostType.Project]: + 'bg-accent-cabbage-subtler text-accent-cabbage-default', + [ExperiencePostType.Media]: + 'bg-accent-cheese-subtler text-accent-cheese-default', + [ExperiencePostType.Achievement]: + 'bg-accent-bun-subtler text-accent-bun-default', + [ExperiencePostType.OpenSource]: + 'bg-accent-blueCheese-subtler text-accent-blueCheese-default', +}; + +const TYPE_LABELS = { + [ExperiencePostType.Milestone]: 'Milestone', + [ExperiencePostType.Publication]: 'Publication', + [ExperiencePostType.Project]: 'Project', + [ExperiencePostType.Media]: 'Media', + [ExperiencePostType.Achievement]: 'Achievement', + [ExperiencePostType.OpenSource]: 'Open Source', +}; + +const formatDate = (dateStr: string): string => { + return new Date(dateStr).toLocaleDateString('en-US', { + month: 'short', + year: 'numeric', + }); +}; + +interface ExperiencePostItemProps { + post: ExperiencePost; +} + +export function ExperiencePostItem({ + post, +}: ExperiencePostItemProps): ReactElement { + const Icon = TYPE_ICONS[post.type]; + const colorClass = TYPE_COLORS[post.type]; + const label = TYPE_LABELS[post.type]; + + const renderMeta = (): ReactElement | null => { + switch (post.type) { + case ExperiencePostType.Publication: { + const pub = post as PublicationPost; + return ( +
+ {pub.publisher && ( + + {pub.publisher} + + )} + {pub.readTime && ( + <> + · + + {pub.readTime} min read + + + )} +
+ ); + } + case ExperiencePostType.Project: { + const proj = post as ProjectPost; + return ( +
+ {proj.technologies?.slice(0, 4).map((tech) => ( + + ))} + {proj.status && ( + + )} +
+ ); + } + case ExperiencePostType.Media: { + const media = post as MediaPost; + return ( +
+ {media.venue && ( + + {media.venue} + + )} + {media.duration && ( + <> + · + + {media.duration} min + + + )} +
+ ); + } + case ExperiencePostType.Achievement: { + const achievement = post as AchievementPost; + return ( +
+ {achievement.issuer && ( + + {achievement.issuer} + + )} + {achievement.credentialId && ( + <> + · + + ID: {achievement.credentialId} + + + )} +
+ ); + } + case ExperiencePostType.OpenSource: { + const oss = post as OpenSourcePost; + return ( +
+ {oss.stars !== undefined && ( +
+ + + {oss.stars.toLocaleString()} + +
+ )} + {oss.contributions !== undefined && ( + + {oss.contributions} contributions + + )} +
+ ); + } + default: + return null; + } + }; + + const content = ( +
+ {/* Thumbnail or icon */} + {post.image ? ( + {post.title} + ) : ( +
+ +
+ )} + + {/* Content */} +
+
+
+
+ + + {formatDate(post.date)} + +
+ + {post.title} + +
+ {post.url && ( + + )} +
+ + {post.description && ( + + {post.description} + + )} + + {renderMeta()} +
+
+ ); + + if (post.url) { + return ( + + {content} + + ); + } + + return
{content}
; +} diff --git a/packages/shared/src/features/profile/components/experience/experience-posts/ExperiencePostsSection.tsx b/packages/shared/src/features/profile/components/experience/experience-posts/ExperiencePostsSection.tsx new file mode 100644 index 0000000000..e3f6c971c7 --- /dev/null +++ b/packages/shared/src/features/profile/components/experience/experience-posts/ExperiencePostsSection.tsx @@ -0,0 +1,221 @@ +import type { ReactElement } from 'react'; +import React, { useState, useMemo } from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyType, + TypographyColor, +} from '../../../../../components/typography/Typography'; +import { ArrowIcon, HashtagIcon } from '../../../../../components/icons'; +import { IconSize } from '../../../../../components/Icon'; +import { Pill, PillSize } from '../../../../../components/Pill'; +import type { ExperiencePost } from './types'; +import { ExperiencePostType } from './types'; +import { ExperiencePostItem } from './ExperiencePostItem'; + +const TYPE_LABELS: Record = { + all: 'All', + [ExperiencePostType.Milestone]: 'Milestones', + [ExperiencePostType.Publication]: 'Publications', + [ExperiencePostType.Project]: 'Projects', + [ExperiencePostType.Media]: 'Media', + [ExperiencePostType.Achievement]: 'Achievements', + [ExperiencePostType.OpenSource]: 'Open Source', +}; + +interface ExperiencePostsSectionProps { + posts: ExperiencePost[]; + initiallyOpen?: boolean; +} + +export function ExperiencePostsSection({ + posts, + initiallyOpen = false, +}: ExperiencePostsSectionProps): ReactElement { + const [isOpen, setIsOpen] = useState(initiallyOpen); + const [activeFilter, setActiveFilter] = useState( + 'all', + ); + const [showAll, setShowAll] = useState(false); + + // Get unique post types for filter tabs + const availableTypes = useMemo(() => { + const types = new Set(posts.map((p) => p.type)); + return Array.from(types); + }, [posts]); + + // Filter posts by type + const filteredPosts = useMemo(() => { + if (activeFilter === 'all') { + return posts; + } + return posts.filter((p) => p.type === activeFilter); + }, [posts, activeFilter]); + + // Limit visible posts + const visiblePosts = showAll ? filteredPosts : filteredPosts.slice(0, 3); + const hiddenCount = filteredPosts.length - 3; + + // Count by type for preview + const typeCounts = useMemo(() => { + return posts.reduce((acc, post) => { + acc[post.type] = (acc[post.type] || 0) + 1; + return acc; + }, {} as Record); + }, [posts]); + + return ( +
+ {/* Header */} + + + {/* Collapsible content */} +
+
+ {/* Filter tabs */} + {availableTypes.length > 1 && ( +
+ + {availableTypes.map((type) => ( + + ))} +
+ )} + + {/* Posts list */} +
+ {visiblePosts.map((post, index) => ( +
0, + })} + > + +
+ ))} +
+ + {/* Show more/less */} + {hiddenCount > 0 && !showAll && ( + + )} + {showAll && filteredPosts.length > 3 && ( + + )} +
+
+
+ ); +} diff --git a/packages/shared/src/features/profile/components/experience/experience-posts/ExperienceTimeline.tsx b/packages/shared/src/features/profile/components/experience/experience-posts/ExperienceTimeline.tsx new file mode 100644 index 0000000000..73cc2c786b --- /dev/null +++ b/packages/shared/src/features/profile/components/experience/experience-posts/ExperienceTimeline.tsx @@ -0,0 +1,378 @@ +import type { ReactElement } from 'react'; +import React, { useState, useMemo } from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyType, + TypographyColor, + TypographyTag, +} from '../../../../../components/typography/Typography'; +import { + ArrowIcon, + FlagIcon, + LinkIcon, + TerminalIcon, + PlayIcon, + StarIcon, + GitHubIcon, + OpenLinkIcon, +} from '../../../../../components/icons'; +import { IconSize } from '../../../../../components/Icon'; +import { Image, ImageType } from '../../../../../components/image/Image'; +import type { + ExperiencePost, + ProjectPost, + PublicationPost, + MediaPost, + AchievementPost, + OpenSourcePost, +} from './types'; +import { ExperiencePostType } from './types'; + +const TYPE_ICONS = { + [ExperiencePostType.Milestone]: FlagIcon, + [ExperiencePostType.Publication]: LinkIcon, + [ExperiencePostType.Project]: TerminalIcon, + [ExperiencePostType.Media]: PlayIcon, + [ExperiencePostType.Achievement]: StarIcon, + [ExperiencePostType.OpenSource]: GitHubIcon, +}; + +const TYPE_COLORS = { + [ExperiencePostType.Milestone]: 'bg-accent-onion-default', + [ExperiencePostType.Publication]: 'bg-accent-water-default', + [ExperiencePostType.Project]: 'bg-accent-cabbage-default', + [ExperiencePostType.Media]: 'bg-accent-cheese-default', + [ExperiencePostType.Achievement]: 'bg-accent-bun-default', + [ExperiencePostType.OpenSource]: 'bg-accent-blueCheese-default', +}; + +const TYPE_LABELS = { + [ExperiencePostType.Milestone]: 'Milestone', + [ExperiencePostType.Publication]: 'Publication', + [ExperiencePostType.Project]: 'Project', + [ExperiencePostType.Media]: 'Media', + [ExperiencePostType.Achievement]: 'Achievement', + [ExperiencePostType.OpenSource]: 'Open Source', +}; + +const formatDate = (dateStr: string): string => { + const date = new Date(dateStr); + return date.toLocaleDateString('en-US', { + month: 'short', + year: 'numeric', + }); +}; + +interface TimelineNodeProps { + post: ExperiencePost; + isLast: boolean; +} + +function TimelineNode({ post, isLast }: TimelineNodeProps): ReactElement { + const Icon = TYPE_ICONS[post.type]; + const color = TYPE_COLORS[post.type]; + const label = TYPE_LABELS[post.type]; + const dateStr = formatDate(post.date); + + const renderMeta = (): ReactElement | null => { + switch (post.type) { + case ExperiencePostType.Publication: { + const pub = post as PublicationPost; + return pub.publisher ? ( + + {pub.publisher} + + ) : null; + } + case ExperiencePostType.Project: { + const proj = post as ProjectPost; + return proj.technologies?.length ? ( +
+ {proj.technologies.slice(0, 3).map((tech) => ( + + {tech} + + ))} +
+ ) : null; + } + case ExperiencePostType.Media: { + const media = post as MediaPost; + return media.venue ? ( + + {media.venue} + + ) : null; + } + case ExperiencePostType.Achievement: { + const achievement = post as AchievementPost; + return achievement.issuer ? ( + + {achievement.issuer} + + ) : null; + } + case ExperiencePostType.OpenSource: { + const oss = post as OpenSourcePost; + return oss.stars ? ( +
+ + + {oss.stars.toLocaleString()} + +
+ ) : null; + } + default: + return null; + } + }; + + const content = ( +
+ {/* Timeline connector */} +
+ {/* Node */} +
+ +
+ {/* Connecting line */} + {!isLast && ( +
+ )} +
+ + {/* Content card */} +
+ {/* Header row */} +
+
+
+ + {label} + + · + + {dateStr} + +
+ + {post.title} + +
+ + {/* Thumbnail */} + {post.image && ( + {post.title} + )} +
+ + {/* Description */} + {post.description && ( + + {post.description} + + )} + + {/* Meta info */} +
+ {renderMeta()} + {post.url && ( + + )} +
+
+
+ ); + + if (post.url) { + return ( + + {content} + + ); + } + + return content; +} + +interface ExperienceTimelineProps { + posts: ExperiencePost[]; + initiallyOpen?: boolean; +} + +export function ExperienceTimeline({ + posts, + initiallyOpen = false, +}: ExperienceTimelineProps): ReactElement { + const [isOpen, setIsOpen] = useState(initiallyOpen); + const [showAll, setShowAll] = useState(false); + + // Sort posts by date descending + const sortedPosts = useMemo(() => { + return [...posts].sort( + (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(), + ); + }, [posts]); + + const visiblePosts = showAll ? sortedPosts : sortedPosts.slice(0, 4); + const hiddenCount = sortedPosts.length - 4; + const postCount = posts.length; + + return ( +
+ {/* Header */} + + + {/* Collapsible content */} +
+
+ {/* Timeline */} +
+ {visiblePosts.map((post, index) => ( + + ))} +
+ + {/* Show more/less */} + {hiddenCount > 0 && !showAll && ( + + )} + {showAll && sortedPosts.length > 4 && ( + + )} +
+
+
+ ); +} diff --git a/packages/shared/src/features/profile/components/experience/experience-posts/index.ts b/packages/shared/src/features/profile/components/experience/experience-posts/index.ts new file mode 100644 index 0000000000..f128807c50 --- /dev/null +++ b/packages/shared/src/features/profile/components/experience/experience-posts/index.ts @@ -0,0 +1,4 @@ +export * from './types'; +export { ExperiencePostItem } from './ExperiencePostItem'; +export { ExperiencePostsSection } from './ExperiencePostsSection'; +export { ExperienceTimeline } from './ExperienceTimeline'; diff --git a/packages/shared/src/features/profile/components/experience/experience-posts/types.ts b/packages/shared/src/features/profile/components/experience/experience-posts/types.ts new file mode 100644 index 0000000000..a68bc08dcc --- /dev/null +++ b/packages/shared/src/features/profile/components/experience/experience-posts/types.ts @@ -0,0 +1,183 @@ +/** + * Types for experience-linked posts/content + * These represent content that users can attach to their work experiences + */ + +export enum ExperiencePostType { + Milestone = 'milestone', + Publication = 'publication', + Project = 'project', + Media = 'media', + Achievement = 'achievement', + OpenSource = 'opensource', +} + +export interface ExperiencePostBase { + id: string; + type: ExperiencePostType; + title: string; + description?: string; + date: string; + url?: string; + image?: string; +} + +export interface MilestonePost extends ExperiencePostBase { + type: ExperiencePostType.Milestone; + milestone: string; // e.g., "Promoted to Senior", "1 Year Anniversary" +} + +export interface PublicationPost extends ExperiencePostBase { + type: ExperiencePostType.Publication; + publisher?: string; // e.g., "Medium", "Dev.to", "Company Blog" + readTime?: number; // in minutes +} + +export interface ProjectPost extends ExperiencePostBase { + type: ExperiencePostType.Project; + technologies?: string[]; + status?: 'completed' | 'in-progress' | 'launched'; +} + +export interface MediaPost extends ExperiencePostBase { + type: ExperiencePostType.Media; + mediaType: 'video' | 'podcast' | 'presentation' | 'talk'; + duration?: number; // in minutes + venue?: string; // e.g., "React Conf 2024", "Company All-Hands" +} + +export interface AchievementPost extends ExperiencePostBase { + type: ExperiencePostType.Achievement; + issuer?: string; // e.g., "AWS", "Google", "Company" + credentialId?: string; +} + +export interface OpenSourcePost extends ExperiencePostBase { + type: ExperiencePostType.OpenSource; + repository?: string; + stars?: number; + contributions?: number; +} + +export type ExperiencePost = + | MilestonePost + | PublicationPost + | ProjectPost + | MediaPost + | AchievementPost + | OpenSourcePost; + +// Post type metadata for display +export const POST_TYPE_META: Record< + ExperiencePostType, + { label: string; icon: string; color: string } +> = { + [ExperiencePostType.Milestone]: { + label: 'Milestone', + icon: 'Flag', + color: 'var(--theme-accent-onion-default)', + }, + [ExperiencePostType.Publication]: { + label: 'Publication', + icon: 'Link', + color: 'var(--theme-accent-water-default)', + }, + [ExperiencePostType.Project]: { + label: 'Project', + icon: 'Terminal', + color: 'var(--theme-accent-cabbage-default)', + }, + [ExperiencePostType.Media]: { + label: 'Media', + icon: 'Play', + color: 'var(--theme-accent-cheese-default)', + }, + [ExperiencePostType.Achievement]: { + label: 'Achievement', + icon: 'Star', + color: 'var(--theme-accent-bun-default)', + }, + [ExperiencePostType.OpenSource]: { + label: 'Open Source', + icon: 'GitHub', + color: 'var(--theme-accent-blueCheese-default)', + }, +}; + +// Mock data generator +export const generateMockExperiencePosts = (): ExperiencePost[] => { + return [ + { + id: '1', + type: ExperiencePostType.Milestone, + title: 'Promoted to Senior Software Engineer', + description: + 'Recognized for technical leadership and mentoring contributions to the team.', + date: '2024-06-15', + milestone: 'Promotion', + image: + 'https://daily-now-res.cloudinary.com/image/upload/v1/placeholders/1', + }, + { + id: '2', + type: ExperiencePostType.Publication, + title: 'Building Scalable React Applications with Module Federation', + description: + 'A deep dive into micro-frontend architecture and how we scaled our platform.', + date: '2024-03-20', + url: 'https://engineering.example.com/module-federation', + publisher: 'Company Engineering Blog', + readTime: 12, + image: + 'https://daily-now-res.cloudinary.com/image/upload/v1/placeholders/2', + }, + { + id: '3', + type: ExperiencePostType.Project, + title: 'Platform Performance Optimization Initiative', + description: + 'Led a cross-functional team to improve page load times by 40% through code splitting and caching strategies.', + date: '2024-01-10', + technologies: ['React', 'Webpack', 'Redis', 'CloudFront'], + status: 'completed', + }, + { + id: '4', + type: ExperiencePostType.Media, + title: 'From Monolith to Microservices: Our Journey', + description: + 'Conference talk about our migration strategy and lessons learned.', + date: '2023-11-08', + url: 'https://youtube.com/watch?v=example', + mediaType: 'talk', + duration: 35, + venue: 'React Summit 2023', + image: + 'https://daily-now-res.cloudinary.com/image/upload/v1/placeholders/3', + }, + { + id: '5', + type: ExperiencePostType.Achievement, + title: 'AWS Solutions Architect Professional', + description: 'Achieved professional-level AWS certification.', + date: '2023-09-22', + issuer: 'Amazon Web Services', + credentialId: 'AWS-SAP-12345', + url: 'https://aws.amazon.com/verification', + image: + 'https://daily-now-res.cloudinary.com/image/upload/v1/placeholders/4', + }, + { + id: '6', + type: ExperiencePostType.OpenSource, + title: 'react-virtual-scroll', + description: + 'Created and maintain a high-performance virtualization library for React.', + date: '2023-07-15', + url: 'https://github.com/example/react-virtual-scroll', + repository: 'example/react-virtual-scroll', + stars: 1247, + contributions: 89, + }, + ]; +}; diff --git a/packages/shared/src/features/profile/components/experience/github-integration/ActivityChart.tsx b/packages/shared/src/features/profile/components/experience/github-integration/ActivityChart.tsx new file mode 100644 index 0000000000..cebcca3611 --- /dev/null +++ b/packages/shared/src/features/profile/components/experience/github-integration/ActivityChart.tsx @@ -0,0 +1,151 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Bar, + BarChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from 'recharts'; +import type { ActivityData } from './types'; + +interface ActivityChartProps { + data: ActivityData[]; + height?: number; +} + +const ACTIVITY_COLORS = { + commits: 'var(--theme-accent-onion-default)', + pullRequests: 'var(--theme-accent-water-default)', + reviews: 'var(--theme-accent-cheese-default)', + issues: 'var(--theme-accent-bacon-default)', +}; + +const formatDate = (dateStr: string): string => { + const date = new Date(dateStr); + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +}; + +interface TooltipPayloadItem { + value: number; + dataKey: string; + color: string; +} + +interface CustomTooltipProps { + active?: boolean; + payload?: TooltipPayloadItem[]; + label?: string; +} + +const CustomTooltip = ({ + active, + payload, + label, +}: CustomTooltipProps): ReactElement | null => { + if (!active || !payload?.length) { + return null; + } + + return ( +
+

{label}

+ {payload.map((entry) => ( +
+ + {entry.dataKey}: + {entry.value} +
+ ))} +
+ ); +}; + +export function ActivityChart({ + data, + height = 160, +}: ActivityChartProps): ReactElement { + const chartData = data.map((item) => ({ + ...item, + name: formatDate(item.date), + })); + + return ( +
+ + + + + + } cursor={false} /> + + + + + + + +
+ ); +} diff --git a/packages/shared/src/features/profile/components/experience/github-integration/ExperienceWithGitStats.tsx b/packages/shared/src/features/profile/components/experience/github-integration/ExperienceWithGitStats.tsx new file mode 100644 index 0000000000..a54d497127 --- /dev/null +++ b/packages/shared/src/features/profile/components/experience/github-integration/ExperienceWithGitStats.tsx @@ -0,0 +1,94 @@ +/** + * DEMO COMPONENT + * This is an example showing how GitIntegrationStats can be added to an experience item. + * For demo purposes only - showcases the collapsible GitHub/GitLab integration. + */ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Typography, + TypographyType, + TypographyColor, +} from '../../../../../components/typography/Typography'; +import { Image, ImageType } from '../../../../../components/image/Image'; +import { Pill, PillSize } from '../../../../../components/Pill'; +import { GitIntegrationStats } from './GitIntegrationStats'; +import { generateMockGitStats } from './types'; + +interface ExperienceWithGitStatsProps { + provider?: 'github' | 'gitlab'; +} + +/** + * Demo component showing an experience item with GitHub/GitLab integration + * Use this as reference for how to integrate into the actual UserExperienceItem + */ +export function ExperienceWithGitStats({ + provider = 'github', +}: ExperienceWithGitStatsProps): ReactElement { + // Generate mock data for demo + const gitData = generateMockGitStats(provider); + + return ( +
+ {/* Simulated experience header */} +
+ +
+
+ + Senior Software Engineer + + +
+ + Acme Technologies + + + Jan 2022 - Present | San Francisco, CA + + + {/* Description */} + + Building scalable infrastructure and developer tools. Leading the + platform team responsible for CI/CD pipelines and deployment + automation. + + + {/* Skills */} +
+ {['TypeScript', 'React', 'Node.js'].map((skill) => ( + + ))} +
+
+
+ + {/* GitHub/GitLab Integration - Collapsible section */} + +
+ ); +} diff --git a/packages/shared/src/features/profile/components/experience/github-integration/GitIntegrationStats.tsx b/packages/shared/src/features/profile/components/experience/github-integration/GitIntegrationStats.tsx new file mode 100644 index 0000000000..b9a05c393b --- /dev/null +++ b/packages/shared/src/features/profile/components/experience/github-integration/GitIntegrationStats.tsx @@ -0,0 +1,280 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import classNames from 'classnames'; +import { + Typography, + TypographyType, + TypographyColor, + TypographyTag, +} from '../../../../../components/typography/Typography'; +import { + GitHubIcon, + GitLabIcon, + ArrowIcon, + LockIcon, + SourceIcon, +} from '../../../../../components/icons'; +import { IconSize } from '../../../../../components/Icon'; +import type { GitIntegrationData, Repository } from './types'; +import { LanguageBar } from './LanguageBar'; +import { ActivityChart } from './ActivityChart'; + +interface StatItemProps { + label: string; + value: number | string; +} + +function StatItem({ label, value }: StatItemProps): ReactElement { + return ( +
+ + {typeof value === 'number' ? value.toLocaleString() : value} + + + {label} + +
+ ); +} + +interface RepoItemProps { + repo: Repository; +} + +function RepoItem({ repo }: RepoItemProps): ReactElement { + return ( + +
+ + + {repo.name} + + {repo.isPrivate && ( + + )} +
+
+ + {repo.commits.toLocaleString()} commits + +
+ {repo.languages.slice(0, 3).map((lang) => ( + + ))} +
+
+
+ ); +} + +interface GitIntegrationStatsProps { + data: GitIntegrationData; + initiallyOpen?: boolean; +} + +export function GitIntegrationStats({ + data, + initiallyOpen = false, +}: GitIntegrationStatsProps): ReactElement { + const [isOpen, setIsOpen] = useState(initiallyOpen); + const [showAllRepos, setShowAllRepos] = useState(false); + + const ProviderIcon = data.provider === 'github' ? GitHubIcon : GitLabIcon; + const providerName = data.provider === 'github' ? 'GitHub' : 'GitLab'; + + const repoCount = data.repositories.length; + const visibleRepos = showAllRepos + ? data.repositories + : data.repositories.slice(0, 3); + const hiddenRepoCount = repoCount - 3; + + return ( +
+ {/* Header */} + + + {/* Collapsible content */} +
+
+ {/* Stats grid */} +
+ + + + +
+ + {/* Languages */} +
+ + Languages + + +
+ + {/* Activity chart */} +
+ + Activity (12 weeks) + + +
+ + {/* Repositories */} +
+ + Linked Repositories + +
+ {visibleRepos.map((repo, index) => ( +
0, + })} + > + +
+ ))} + {hiddenRepoCount > 0 && !showAllRepos && ( + + )} + {showAllRepos && repoCount > 3 && ( + + )} +
+
+ + {/* Streak */} +
+ + Current streak + + + {data.aggregatedStats.contributionStreak} days + +
+
+
+
+ ); +} diff --git a/packages/shared/src/features/profile/components/experience/github-integration/LanguageBar.tsx b/packages/shared/src/features/profile/components/experience/github-integration/LanguageBar.tsx new file mode 100644 index 0000000000..baa01838c8 --- /dev/null +++ b/packages/shared/src/features/profile/components/experience/github-integration/LanguageBar.tsx @@ -0,0 +1,67 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Typography, + TypographyType, + TypographyColor, +} from '../../../../../components/typography/Typography'; +import { Tooltip } from '../../../../../components/tooltip/Tooltip'; +import type { RepositoryLanguage } from './types'; + +interface LanguageBarProps { + languages: RepositoryLanguage[]; + showLegend?: boolean; +} + +export function LanguageBar({ + languages, + showLegend = true, +}: LanguageBarProps): ReactElement { + return ( +
+ {/* Progress bar */} +
+ {languages.map((lang) => ( + +
+ + ))} +
+ + {/* Legend */} + {showLegend && ( +
+ {languages.map((lang) => ( +
+ + + {lang.name} + + + {lang.percentage.toFixed(1)}% + +
+ ))} +
+ )} +
+ ); +} diff --git a/packages/shared/src/features/profile/components/experience/github-integration/index.ts b/packages/shared/src/features/profile/components/experience/github-integration/index.ts new file mode 100644 index 0000000000..3d47c8f9fd --- /dev/null +++ b/packages/shared/src/features/profile/components/experience/github-integration/index.ts @@ -0,0 +1,5 @@ +export * from './types'; +export { LanguageBar } from './LanguageBar'; +export { ActivityChart } from './ActivityChart'; +export { GitIntegrationStats } from './GitIntegrationStats'; +export { ExperienceWithGitStats } from './ExperienceWithGitStats'; diff --git a/packages/shared/src/features/profile/components/experience/github-integration/types.ts b/packages/shared/src/features/profile/components/experience/github-integration/types.ts new file mode 100644 index 0000000000..b60195bd45 --- /dev/null +++ b/packages/shared/src/features/profile/components/experience/github-integration/types.ts @@ -0,0 +1,244 @@ +/** + * Mock types for GitHub/GitLab integration + * These represent the data we would fetch from GitHub/GitLab APIs + */ + +export interface RepositoryLanguage { + name: string; + percentage: number; + color: string; +} + +export interface ActivityData { + date: string; + commits: number; + pullRequests: number; + reviews: number; + issues: number; +} + +export interface Repository { + name: string; + url: string; + commits: number; + pullRequests: number; + reviews: number; + issues: number; + languages: RepositoryLanguage[]; + isPrivate?: boolean; +} + +export interface AggregatedStats { + totalCommits: number; + totalPullRequests: number; + totalReviews: number; + totalIssues: number; + languages: RepositoryLanguage[]; + activityHistory: ActivityData[]; + contributionStreak: number; + lastActivityDate: string; +} + +export interface GitIntegrationData { + provider: 'github' | 'gitlab'; + username: string; + repositories: Repository[]; + aggregatedStats: AggregatedStats; + connected: boolean; + lastSynced?: string; +} + +// Language colors for common languages +const LANGUAGE_COLORS: Record = { + TypeScript: '#3178c6', + JavaScript: '#f7df1e', + Python: '#3572A5', + Go: '#00ADD8', + Rust: '#dea584', + Java: '#b07219', + 'C++': '#f34b7d', + C: '#555555', + Ruby: '#701516', + PHP: '#4F5D95', + Swift: '#F05138', + Kotlin: '#A97BFF', + CSS: '#563d7c', + HTML: '#e34c26', + Shell: '#89e051', + Dockerfile: '#384d54', + Other: '#8b8b8b', +}; + +// Aggregate languages from multiple repos into combined percentages +const aggregateLanguages = (repos: Repository[]): RepositoryLanguage[] => { + const languageTotals: Record = {}; + let totalWeight = 0; + + repos.forEach((repo) => { + // Weight by repo activity (commits) + const repoWeight = repo.commits; + totalWeight += repoWeight; + + repo.languages.forEach((lang) => { + const contribution = (lang.percentage / 100) * repoWeight; + languageTotals[lang.name] = + (languageTotals[lang.name] || 0) + contribution; + }); + }); + + // Convert to percentages and sort + const languages = Object.entries(languageTotals) + .map(([name, weight]) => ({ + name, + percentage: totalWeight > 0 ? (weight / totalWeight) * 100 : 0, + color: LANGUAGE_COLORS[name] || LANGUAGE_COLORS.Other, + })) + .sort((a, b) => b.percentage - a.percentage); + + // Keep top 5, group rest as "Other" + if (languages.length > 5) { + const top5 = languages.slice(0, 5); + const otherPercentage = languages + .slice(5) + .reduce((sum, l) => sum + l.percentage, 0); + + if (otherPercentage > 0) { + const existingOther = top5.find((l) => l.name === 'Other'); + if (existingOther) { + existingOther.percentage += otherPercentage; + } else { + top5.push({ + name: 'Other', + percentage: otherPercentage, + color: LANGUAGE_COLORS.Other, + }); + } + } + return top5; + } + + return languages; +}; + +// Generate mock activity history +const generateActivityHistory = (): ActivityData[] => { + const activityHistory: ActivityData[] = []; + const now = new Date(); + + for (let i = 11; i >= 0; i -= 1) { + const date = new Date(now); + date.setDate(date.getDate() - i * 7); + activityHistory.push({ + date: date.toISOString().split('T')[0], + commits: Math.floor(Math.random() * 45) + 10, + pullRequests: Math.floor(Math.random() * 15) + 2, + reviews: Math.floor(Math.random() * 20) + 5, + issues: Math.floor(Math.random() * 8) + 1, + }); + } + + return activityHistory; +}; + +// Mock data generator for demo purposes - now with multiple repos +export const generateMockGitStats = ( + provider: 'github' | 'gitlab' = 'github', +): GitIntegrationData => { + // Simulate multiple repositories for a company + const repositories: Repository[] = [ + { + name: 'webapp', + url: `https://${provider}.com/acme/webapp`, + commits: 847, + pullRequests: 56, + reviews: 89, + issues: 23, + languages: [ + { name: 'TypeScript', percentage: 65, color: '#3178c6' }, + { name: 'CSS', percentage: 20, color: '#563d7c' }, + { name: 'JavaScript', percentage: 10, color: '#f7df1e' }, + { name: 'HTML', percentage: 5, color: '#e34c26' }, + ], + }, + { + name: 'api-gateway', + url: `https://${provider}.com/acme/api-gateway`, + commits: 423, + pullRequests: 34, + reviews: 67, + issues: 12, + languages: [ + { name: 'Go', percentage: 85, color: '#00ADD8' }, + { name: 'Dockerfile', percentage: 10, color: '#384d54' }, + { name: 'Shell', percentage: 5, color: '#89e051' }, + ], + }, + { + name: 'shared-components', + url: `https://${provider}.com/acme/shared-components`, + commits: 312, + pullRequests: 28, + reviews: 45, + issues: 8, + languages: [ + { name: 'TypeScript', percentage: 80, color: '#3178c6' }, + { name: 'CSS', percentage: 15, color: '#563d7c' }, + { name: 'JavaScript', percentage: 5, color: '#f7df1e' }, + ], + }, + { + name: 'ml-pipeline', + url: `https://${provider}.com/acme/ml-pipeline`, + commits: 189, + pullRequests: 15, + reviews: 22, + issues: 6, + languages: [ + { name: 'Python', percentage: 90, color: '#3572A5' }, + { name: 'Shell', percentage: 7, color: '#89e051' }, + { name: 'Dockerfile', percentage: 3, color: '#384d54' }, + ], + }, + { + name: 'infrastructure', + url: `https://${provider}.com/acme/infrastructure`, + commits: 156, + pullRequests: 12, + reviews: 18, + issues: 4, + isPrivate: true, + languages: [ + { name: 'Shell', percentage: 45, color: '#89e051' }, + { name: 'Python', percentage: 35, color: '#3572A5' }, + { name: 'Dockerfile', percentage: 20, color: '#384d54' }, + ], + }, + ]; + + // Aggregate stats across all repos + const totalCommits = repositories.reduce((sum, r) => sum + r.commits, 0); + const totalPullRequests = repositories.reduce( + (sum, r) => sum + r.pullRequests, + 0, + ); + const totalReviews = repositories.reduce((sum, r) => sum + r.reviews, 0); + const totalIssues = repositories.reduce((sum, r) => sum + r.issues, 0); + + return { + provider, + username: 'johndoe', + repositories, + connected: true, + lastSynced: new Date().toISOString(), + aggregatedStats: { + totalCommits, + totalPullRequests, + totalReviews, + totalIssues, + languages: aggregateLanguages(repositories), + activityHistory: generateActivityHistory(), + contributionStreak: 23, + lastActivityDate: new Date().toISOString(), + }, + }; +}; diff --git a/packages/shared/src/features/profile/components/mocks/SetupShowcase.tsx b/packages/shared/src/features/profile/components/mocks/SetupShowcase.tsx new file mode 100644 index 0000000000..0ce3f919e0 --- /dev/null +++ b/packages/shared/src/features/profile/components/mocks/SetupShowcase.tsx @@ -0,0 +1,293 @@ +import type { ReactElement } from 'react'; +import React, { useState } from 'react'; +import { + Typography, + TypographyType, + TypographyTag, + TypographyColor, +} from '../../../../components/typography/Typography'; +import { IconSize } from '../../../../components/Icon'; +import { + TerminalIcon, + SettingsIcon, + CameraIcon, + EditIcon, + PlusIcon, +} from '../../../../components/icons'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../../components/buttons/Button'; + +interface SetupImageProps { + src: string; + alt: string; + caption?: string; +} + +const SetupImage = ({ src, alt, caption }: SetupImageProps): ReactElement => ( +
+
+ {alt} +
+ {caption && ( +
+ + {caption} + +
+ )} +
+); + +interface ToolItemProps { + name: string; + category: string; + iconUrl?: string; +} + +const ToolItem = ({ name, category, iconUrl }: ToolItemProps): ReactElement => ( +
+
+ {iconUrl ? ( + {name} + ) : ( + + )} +
+
+ + {name} + + + {category} + +
+
+); + +type TabType = 'workspace' | 'tools'; + +interface CategoryHeaderProps { + title: string; +} + +const CategoryHeader = ({ title }: CategoryHeaderProps): ReactElement => ( +
+ + {title} + +
+); + +export const SetupShowcase = (): ReactElement => { + const [activeTab, setActiveTab] = useState('workspace'); + + const tabs: { id: TabType; label: string; icon: ReactElement }[] = [ + { + id: 'workspace', + label: 'Workspace', + icon: , + }, + { + id: 'tools', + label: 'Tools', + icon: , + }, + ]; + + return ( +
+
+ + My Setup + + +
+ + {/* Tab Navigation */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Workspace Tab */} + {activeTab === 'workspace' && ( +
+ +
+ + + +
+
+ + Gear Highlights + +
+ {[ + 'MacBook Pro 16"', + 'LG 27" 4K Monitor', + 'Keychron K2', + 'Logitech MX Master 3', + 'Herman Miller Aeron', + ].map((item) => ( + + + {item} + + + ))} + +
+
+
+ )} + + {/* Tools Tab */} + {activeTab === 'tools' && ( +
+
+ +
+ + + + +
+
+ +
+ +
+ + + +
+
+ + +
+ )} +
+ ); +}; diff --git a/packages/shared/src/features/profile/components/mocks/StackDNA.tsx b/packages/shared/src/features/profile/components/mocks/StackDNA.tsx new file mode 100644 index 0000000000..fab713b594 --- /dev/null +++ b/packages/shared/src/features/profile/components/mocks/StackDNA.tsx @@ -0,0 +1,225 @@ +import type { ReactElement } from 'react'; +import React from 'react'; +import { + Typography, + TypographyType, + TypographyColor, +} from '../../../../components/typography/Typography'; +import { IconSize } from '../../../../components/Icon'; +import { TerminalIcon, EditIcon, PlusIcon } from '../../../../components/icons'; +import { + Button, + ButtonSize, + ButtonVariant, +} from '../../../../components/buttons/Button'; + +interface StackItemProps { + icon: ReactElement; + name: string; + level?: 'expert' | 'proficient' | 'hobby'; + years?: number; +} + +const StackItem = ({ + icon, + name, + level = 'proficient', + years, +}: StackItemProps): ReactElement => { + const levelStyles = { + expert: 'bg-action-upvote-float text-action-upvote-default', + proficient: 'bg-accent-cabbage-float text-accent-cabbage-default', + hobby: 'bg-accent-blueCheese-float text-accent-blueCheese-default', + }; + + return ( +
+ {icon} + + {name} + + {years && ( + + {years}y + + )} + + {level} + +
+ ); +}; + +interface HotTakeItemProps { + icon: string; + label: string; + content: string; +} + +const HotTakeItem = ({ + icon, + label, + content, +}: HotTakeItemProps): ReactElement => ( +
+ + {icon} + +
+
+ + {label} + +
+ + {content} + +
+
+); + +export const StackDNA = (): ReactElement => { + return ( +
+
+ + Stack & Tools + + +
+ + {/* Primary Stack */} +
+ + Primary Stack + +
+ } + name="TypeScript" + level="expert" + years={5} + /> + } + name="React" + level="expert" + years={6} + /> + } + name="Node.js" + level="proficient" + years={4} + /> + } + name="PostgreSQL" + level="proficient" + years={3} + /> + +
+
+ + {/* Just for Fun */} +
+ + Just for Fun + +
+ } + name="Rust" + level="hobby" + /> + } + name="Go" + level="hobby" + /> + +
+
+ + {/* Hot Takes */} +
+ + Hot Takes + +
+ +
+ +
+ +
+ +
+
+
+ ); +}; diff --git a/packages/shared/src/features/profile/components/mocks/index.ts b/packages/shared/src/features/profile/components/mocks/index.ts new file mode 100644 index 0000000000..9809ede5c1 --- /dev/null +++ b/packages/shared/src/features/profile/components/mocks/index.ts @@ -0,0 +1,2 @@ +export { StackDNA } from './StackDNA'; +export { SetupShowcase } from './SetupShowcase'; diff --git a/packages/storybook/stories/components/MultiSourceHeatmap.stories.tsx b/packages/storybook/stories/components/MultiSourceHeatmap.stories.tsx new file mode 100644 index 0000000000..a7f1c0b003 --- /dev/null +++ b/packages/storybook/stories/components/MultiSourceHeatmap.stories.tsx @@ -0,0 +1,132 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import { subDays } from 'date-fns'; +import { + MultiSourceHeatmap, + ActivityOverviewCard, + generateMockMultiSourceActivity, +} from '@dailydotdev/shared/src/components/MultiSourceHeatmap'; + +const meta: Meta = { + title: 'Components/MultiSourceHeatmap', + parameters: { + layout: 'padded', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +const endDate = new Date(); +const startDate = subDays(endDate, 365); +const mockActivities = generateMockMultiSourceActivity(startDate, endDate); + +/** + * The full activity overview card with collapsible heatmap. + * Aggregates data from GitHub, GitLab, daily.dev, and more. + */ +export const ActivityCard: StoryObj = { + render: () => , +}; + +/** + * Activity card in collapsed state - shows quick stats preview. + */ +export const ActivityCardCollapsed: StoryObj = { + render: () => , +}; + +/** + * Standalone heatmap component with all features. + */ +export const HeatmapFull: StoryObj = { + render: () => ( + + ), +}; + +/** + * Heatmap without stats - just the grid and legend. + */ +export const HeatmapNoStats: StoryObj = { + render: () => ( + + ), +}; + +/** + * Minimal heatmap - just the grid. + */ +export const HeatmapMinimal: StoryObj = { + render: () => ( + + ), +}; + +/** + * 6-month view of activity. + */ +export const SixMonthView: StoryObj = { + render: () => { + const sixMonthStart = subDays(endDate, 180); + const sixMonthActivities = generateMockMultiSourceActivity( + sixMonthStart, + endDate, + ); + return ( + + ); + }, +}; + +/** + * 3-month view - compact. + */ +export const ThreeMonthView: StoryObj = { + render: () => { + const threeMonthStart = subDays(endDate, 90); + const threeMonthActivities = generateMockMultiSourceActivity( + threeMonthStart, + endDate, + ); + return ( + + ); + }, +}; diff --git a/packages/storybook/stories/features/profile/ExperiencePosts.stories.tsx b/packages/storybook/stories/features/profile/ExperiencePosts.stories.tsx new file mode 100644 index 0000000000..33d4b202b2 --- /dev/null +++ b/packages/storybook/stories/features/profile/ExperiencePosts.stories.tsx @@ -0,0 +1,190 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import { + ExperiencePostsSection, + ExperiencePostItem, + ExperienceTimeline, + generateMockExperiencePosts, + ExperiencePostType, +} from '@dailydotdev/shared/src/features/profile/components/experience/experience-posts'; +import type { ExperiencePost } from '@dailydotdev/shared/src/features/profile/components/experience/experience-posts'; + +const meta: Meta = { + title: 'Features/Profile/ExperiencePosts', + parameters: { + layout: 'padded', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +const mockPosts = generateMockExperiencePosts(); + +/** + * Full experience posts section with all post types. + * Features filter tabs for different categories. + */ +export const AllPosts: StoryObj = { + render: () => , +}; + +/** + * Collapsed state showing preview counts. + */ +export const Collapsed: StoryObj = { + render: () => ( + + ), +}; + +/** + * Single milestone post item. + */ +export const MilestonePost: StoryObj = { + render: () => { + const post = mockPosts.find( + (p) => p.type === ExperiencePostType.Milestone, + ) as ExperiencePost; + return ( +
+ +
+ ); + }, +}; + +/** + * Single publication post item. + */ +export const PublicationPost: StoryObj = { + render: () => { + const post = mockPosts.find( + (p) => p.type === ExperiencePostType.Publication, + ) as ExperiencePost; + return ( +
+ +
+ ); + }, +}; + +/** + * Single project post item with technologies. + */ +export const ProjectPost: StoryObj = { + render: () => { + const post = mockPosts.find( + (p) => p.type === ExperiencePostType.Project, + ) as ExperiencePost; + return ( +
+ +
+ ); + }, +}; + +/** + * Single media post item (talk/video). + */ +export const MediaPost: StoryObj = { + render: () => { + const post = mockPosts.find( + (p) => p.type === ExperiencePostType.Media, + ) as ExperiencePost; + return ( +
+ +
+ ); + }, +}; + +/** + * Single achievement post item (certification). + */ +export const AchievementPost: StoryObj = { + render: () => { + const post = mockPosts.find( + (p) => p.type === ExperiencePostType.Achievement, + ) as ExperiencePost; + return ( +
+ +
+ ); + }, +}; + +/** + * Single open source post item with stars. + */ +export const OpenSourcePost: StoryObj = { + render: () => { + const post = mockPosts.find( + (p) => p.type === ExperiencePostType.OpenSource, + ) as ExperiencePost; + return ( +
+ +
+ ); + }, +}; + +/** + * Section with only a few posts (no "show more"). + */ +export const FewPosts: StoryObj = { + render: () => ( + + ), +}; + +/** + * Section with single post type (no filter tabs). + */ +export const SingleType: StoryObj = { + render: () => { + const milestonesOnly = mockPosts.filter( + (p) => p.type === ExperiencePostType.Milestone, + ); + return ; + }, +}; + +// ============================================ +// TIMELINE VIEW (New funky design!) +// ============================================ + +/** + * Timeline view - The new funky design with vertical timeline, + * colored nodes, connecting lines, and hover effects. + */ +export const Timeline: StoryObj = { + render: () => , +}; + +/** + * Timeline collapsed state - Shows mini colored dots preview. + */ +export const TimelineCollapsed: StoryObj = { + render: () => , +}; + +/** + * Timeline with fewer posts - No "show more" button needed. + */ +export const TimelineFewPosts: StoryObj = { + render: () => ( + + ), +}; diff --git a/packages/storybook/stories/features/profile/GitIntegration.stories.tsx b/packages/storybook/stories/features/profile/GitIntegration.stories.tsx new file mode 100644 index 0000000000..a74f6d71a9 --- /dev/null +++ b/packages/storybook/stories/features/profile/GitIntegration.stories.tsx @@ -0,0 +1,118 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; +import { + GitIntegrationStats, + LanguageBar, + ActivityChart, + ExperienceWithGitStats, + generateMockGitStats, +} from '@dailydotdev/shared/src/features/profile/components/experience/github-integration'; + +const meta: Meta = { + title: 'Features/Profile/GitIntegration', + parameters: { + layout: 'padded', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; + +/** + * Full GitHub integration stats in a collapsible section. + * This is the main component to use for displaying GitHub/GitLab activity. + */ +export const GitHubStats: StoryObj = { + render: () => { + const mockData = generateMockGitStats('github'); + return ; + }, +}; + +/** + * GitLab variant of the integration stats. + */ +export const GitLabStats: StoryObj = { + render: () => { + const mockData = generateMockGitStats('gitlab'); + return ; + }, +}; + +/** + * Collapsed state - shows preview stats. + * Click to expand and see full details. + */ +export const Collapsed: StoryObj = { + render: () => { + const mockData = generateMockGitStats('github'); + return ; + }, +}; + +/** + * Language percentage bar standalone component. + * Shows programming language distribution like on GitHub. + */ +export const LanguageBarOnly: StoryObj = { + render: () => { + const mockData = generateMockGitStats('github'); + return ; + }, +}; + +/** + * Language bar without legend - more compact view. + */ +export const LanguageBarCompact: StoryObj = { + render: () => { + const mockData = generateMockGitStats('github'); + return ; + }, +}; + +/** + * Activity chart standalone component. + * Shows commits, PRs, reviews, and issues over time. + */ +export const ActivityChartOnly: StoryObj = { + render: () => { + const mockData = generateMockGitStats('github'); + return ; + }, +}; + +/** + * Full experience card with GitHub integration. + * This demonstrates how the GitIntegrationStats component + * would look when integrated into an actual experience item. + */ +export const ExperienceWithGitHub: StoryObj = { + render: () => , +}; + +/** + * Full experience card with GitLab integration. + */ +export const ExperienceWithGitLab: StoryObj = { + render: () => , +}; + +/** + * Multiple experiences stacked. + * Shows how multiple experiences with different integrations would look. + */ +export const MultipleExperiences: StoryObj = { + render: () => ( +
+ + +
+ ), +}; diff --git a/packages/webapp/components/layouts/ProfileLayout/index.tsx b/packages/webapp/components/layouts/ProfileLayout/index.tsx index 7c523516a7..212eb7f400 100644 --- a/packages/webapp/components/layouts/ProfileLayout/index.tsx +++ b/packages/webapp/components/layouts/ProfileLayout/index.tsx @@ -25,8 +25,6 @@ import { usePostReferrerContext } from '@dailydotdev/shared/src/contexts/PostRef import { getLayout as getFooterNavBarLayout } from '../FooterNavBarLayout'; import { getLayout as getMainLayout } from '../MainLayout'; import { getTemplatedTitle } from '../utils'; -import { ProfileWidgets } from '../../../../shared/src/features/profile/components/ProfileWidgets/ProfileWidgets'; -import { useProfileSidebarCollapse } from '../../../hooks/useProfileSidebarCollapse'; const Custom404 = dynamic( () => import(/* webpackChunkName: "404" */ '../../../pages/404'), @@ -69,8 +67,6 @@ export const getProfileSeoDefaults = ( export default function ProfileLayout({ user: initialUser, - userStats, - sources, children, }: ProfileLayoutProps): ReactElement { const router = useRouter(); @@ -80,9 +76,6 @@ export default function ProfileLayout({ const { logEvent } = useLogContext(); const { referrerPost } = usePostReferrerContext(); - // Auto-collapse sidebar on small screens - useProfileSidebarCollapse(); - useEffect(() => { if (trackedView || !user) { return; @@ -111,21 +104,11 @@ export default function ProfileLayout({ } return ( -
+
-
- {children} -
- +
{children}
); } diff --git a/packages/webapp/hooks/useProfileSidebarCollapse.ts b/packages/webapp/hooks/useProfileSidebarCollapse.ts deleted file mode 100644 index cf2a4d0738..0000000000 --- a/packages/webapp/hooks/useProfileSidebarCollapse.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { useSettingsContext } from '@dailydotdev/shared/src/contexts/SettingsContext'; - -const COLLAPSE_BREAKPOINT = 1360; - -/** - * Auto-collapses sidebar on profile pages when screen is below 1360px - * */ - -export const useProfileSidebarCollapse = () => { - const { sidebarExpanded, toggleSidebarExpanded, loadedSettings } = - useSettingsContext(); - // Refs to avoid stale closures and unnecessary effect re-runs - const sidebarExpandedRef = useRef(sidebarExpanded); - const toggleSidebarExpandedRef = useRef(toggleSidebarExpanded); - - // Keep refs in sync - useEffect(() => { - sidebarExpandedRef.current = sidebarExpanded; - toggleSidebarExpandedRef.current = toggleSidebarExpanded; - }, [sidebarExpanded, toggleSidebarExpanded]); - - // Auto-collapse on mount (runs once when settings load) - useEffect(() => { - if (!loadedSettings) { - return; - } - - const isSmallScreen = window.matchMedia( - `(max-width: ${COLLAPSE_BREAKPOINT - 1}px)`, - ).matches; - - if (isSmallScreen && sidebarExpanded) { - toggleSidebarExpanded(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [loadedSettings]); - - // Handle resize from big to small (runs once on mount, listener persists) - useEffect(() => { - const mediaQuery = window.matchMedia( - `(max-width: ${COLLAPSE_BREAKPOINT - 1}px)`, - ); - - const handleBreakpointChange = (e: MediaQueryListEvent) => { - // Use refs to access current values without re-creating listener - if (e.matches && sidebarExpandedRef.current) { - toggleSidebarExpandedRef.current(); - } - }; - - mediaQuery.addEventListener('change', handleBreakpointChange); - return () => - mediaQuery.removeEventListener('change', handleBreakpointChange); - }, []); // Empty deps = runs once, listener stays active -}; diff --git a/packages/webapp/pages/[userId]/index.tsx b/packages/webapp/pages/[userId]/index.tsx index 5433ef323a..cff38b0be5 100644 --- a/packages/webapp/pages/[userId]/index.tsx +++ b/packages/webapp/pages/[userId]/index.tsx @@ -1,7 +1,6 @@ import type { ReactElement } from 'react'; import React, { useMemo } from 'react'; import { AboutMe } from '@dailydotdev/shared/src/features/profile/components/AboutMe'; -import { Activity } from '@dailydotdev/shared/src/features/profile/components/Activity'; import { useProfile } from '@dailydotdev/shared/src/hooks/profile/useProfile'; import { useActions, useJoinReferral } from '@dailydotdev/shared/src/hooks'; import { NextSeo } from 'next-seo'; @@ -11,18 +10,12 @@ import { AutofillProfileBanner } from '@dailydotdev/shared/src/features/profile/ import { ProfileUserExperiences } from '@dailydotdev/shared/src/features/profile/components/experience/ProfileUserExperiences'; import { useUploadCv } from '@dailydotdev/shared/src/features/profile/hooks/useUploadCv'; import { ActionType } from '@dailydotdev/shared/src/graphql/actions'; -import { ProfileWidgets } from '@dailydotdev/shared/src/features/profile/components/ProfileWidgets/ProfileWidgets'; -import { - TypographyType, - TypographyTag, - TypographyColor, - Typography, -} from '@dailydotdev/shared/src/components/typography/Typography'; import { useDynamicHeader } from '@dailydotdev/shared/src/useDynamicHeader'; import { Header } from '@dailydotdev/shared/src/components/profile/Header'; import classNames from 'classnames'; import { ProfileCompletion } from '@dailydotdev/shared/src/features/profile/components/ProfileWidgets/ProfileCompletion'; import { Share } from '@dailydotdev/shared/src/features/profile/components/ProfileWidgets/Share'; +import dynamic from 'next/dynamic'; import { getLayout as getProfileLayout, getProfileSeoDefaults, @@ -31,12 +24,26 @@ import { } from '../../components/layouts/ProfileLayout'; import type { ProfileLayoutProps } from '../../components/layouts/ProfileLayout'; -// eslint-disable-next-line @typescript-eslint/no-unused-vars +// Dynamically import mock components to avoid SSR issues +const StackDNA = dynamic( + () => + import( + '@dailydotdev/shared/src/features/profile/components/mocks/StackDNA' + ).then((mod) => mod.StackDNA), + { ssr: false }, +); +const SetupShowcase = dynamic( + () => + import( + '@dailydotdev/shared/src/features/profile/components/mocks/SetupShowcase' + ).then((mod) => mod.SetupShowcase), + { ssr: false }, +); + const ProfilePage = ({ user: initialUser, noindex, userStats, - sources, }: ProfileLayoutProps): ReactElement => { useJoinReferral(); const { status, onUpload, shouldShow } = useUploadCv(); @@ -81,27 +88,13 @@ const ProfilePage = ({ )} {!shouldShowBanner &&
} - + {/* Mock Profile Sections - for preview/demo purposes */} + + + {/* End Mock Profile Sections */} {isUserSame && ( )} -
- - Highlights - - -