@moontra/moonui-pro 2.17.4 → 2.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moontra/moonui-pro",
3
- "version": "2.17.4",
3
+ "version": "2.18.0",
4
4
  "description": "Premium React components for MoonUI - Advanced UI library with 50+ pro components including performance, interactive, and gesture components",
5
5
  "type": "module",
6
6
  "main": "dist/index.mjs",
@@ -92,6 +92,7 @@
92
92
  "@radix-ui/react-toast": "^1.2.14",
93
93
  "@radix-ui/react-tooltip": "^1.2.7",
94
94
  "@tanstack/react-table": "^8.20.5",
95
+ "canvas-confetti": "^1.9.3",
95
96
  "class-variance-authority": "^0.7.0",
96
97
  "clsx": "^2.1.1",
97
98
  "date-fns": "^3.6.0",
@@ -114,6 +115,7 @@
114
115
  "@testing-library/jest-dom": "^6.6.3",
115
116
  "@testing-library/react": "^16.3.0",
116
117
  "@testing-library/user-event": "^14.6.1",
118
+ "@types/canvas-confetti": "^1.9.0",
117
119
  "@types/jest": "^30.0.0",
118
120
  "@types/node": "^18.16.0",
119
121
  "@types/react": "^19.0.0",
@@ -271,7 +271,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel,
271
271
  import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from '../ui/command'
272
272
  import { motion, AnimatePresence, LayoutGroup, useMotionValue, useTransform, animate } from 'framer-motion'
273
273
  import { format, startOfMonth, endOfMonth, startOfWeek, endOfWeek, addDays, addWeeks, addMonths, addYears, subMonths, subYears, isSameMonth, isSameDay, isToday, isBefore, isAfter, differenceInDays, differenceInWeeks, differenceInMonths, parseISO, startOfDay, endOfDay, isWithinInterval, eachDayOfInterval, getDay, setHours, setMinutes, startOfYear, endOfYear, eachMonthOfInterval, getDaysInMonth, getWeek, startOfISOWeek, endOfISOWeek, addHours, subHours, isSameHour, differenceInHours, differenceInMinutes, addMinutes, subMinutes, isSameMinute, setSeconds, setMilliseconds, subDays, subWeeks } from 'date-fns'
274
- import { DragDropContext, Droppable, Draggable, DropResult, DraggableProvided, DraggableStateSnapshot, DroppableProvided } from 'react-beautiful-dnd'
274
+ // Removed react-beautiful-dnd imports - using HTML5 drag & drop instead
275
275
  import { Calendar as CalendarBase } from '../ui/calendar'
276
276
  import { HexColorPicker } from 'react-colorful'
277
277
 
@@ -441,6 +441,9 @@ export const CalendarPro = React.forwardRef<HTMLDivElement, CalendarProProps>(({
441
441
  theme = 'system',
442
442
  ...props
443
443
  }, ref) => {
444
+ // Drag state
445
+ const [draggedEventId, setDraggedEventId] = useState<string | null>(null)
446
+ const [dragOverInfo, setDragOverInfo] = useState<{ date: Date; hour?: number } | null>(null)
444
447
  const [internalSidebarCollapsed, setInternalSidebarCollapsed] = useState(false)
445
448
  const sidebarCollapsed = controlledSidebarCollapsed ?? internalSidebarCollapsed
446
449
 
@@ -617,29 +620,91 @@ export const CalendarPro = React.forwardRef<HTMLDivElement, CalendarProProps>(({
617
620
  }, [selectedEvent, onEventDelete])
618
621
 
619
622
  // Handle drag start
620
- const handleDragStart = useCallback((event: CalendarEvent) => {
623
+ const handleDragStart = useCallback((e: React.DragEvent, event: CalendarEvent) => {
621
624
  if (!allowEventDragging) return
625
+
626
+ setDraggedEventId(event.id)
622
627
  setIsDragging(true)
623
- setDraggedEvent(event)
628
+
629
+ // Store event data in dataTransfer
630
+ e.dataTransfer.effectAllowed = 'move'
631
+ e.dataTransfer.setData('text/plain', event.id)
632
+
633
+ // Create drag image with preserved dimensions
634
+ const dragImage = e.currentTarget.cloneNode(true) as HTMLElement
635
+ const originalRect = e.currentTarget.getBoundingClientRect()
636
+ dragImage.style.opacity = '0.8'
637
+ dragImage.style.position = 'absolute'
638
+ dragImage.style.top = '-1000px'
639
+ dragImage.style.width = `${originalRect.width}px`
640
+ dragImage.style.height = `${originalRect.height}px`
641
+ dragImage.style.boxSizing = 'border-box'
642
+ document.body.appendChild(dragImage)
643
+ e.dataTransfer.setDragImage(dragImage, e.nativeEvent.offsetX, e.nativeEvent.offsetY)
644
+ setTimeout(() => document.body.removeChild(dragImage), 0)
624
645
  }, [allowEventDragging])
625
646
 
626
- // Handle drag end
627
- const handleDragEnd = useCallback((result: DropResult) => {
628
- setIsDragging(false)
629
- setDraggedEvent(null)
630
- setDropTarget(null)
647
+ // Handle drag over
648
+ const handleDragOver = useCallback((e: React.DragEvent, date: Date, hour?: number) => {
649
+ e.preventDefault()
650
+ e.dataTransfer.dropEffect = 'move'
651
+ setDragOverInfo({ date, hour })
652
+ }, [])
653
+
654
+ // Handle drag leave
655
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
656
+ // Only clear if we're leaving the drop zone entirely
657
+ const rect = e.currentTarget.getBoundingClientRect()
658
+ const x = e.clientX
659
+ const y = e.clientY
660
+
661
+ if (x < rect.left || x >= rect.right || y < rect.top || y >= rect.bottom) {
662
+ setDragOverInfo(null)
663
+ }
664
+ }, [])
665
+
666
+ // Handle drop
667
+ const handleDrop = useCallback((e: React.DragEvent, date: Date, hour?: number) => {
668
+ e.preventDefault()
669
+
670
+ const eventId = e.dataTransfer.getData('text/plain')
671
+ const draggedEvent = events.find(ev => ev.id === eventId)
672
+
673
+ if (!draggedEvent || !onEventDrop) return
674
+
675
+ // Calculate new start and end times
676
+ let newStart: Date
677
+ let newEnd: Date
631
678
 
632
- if (!result.destination || !draggedEvent || !onEventDrop) return
679
+ if (hour !== undefined) {
680
+ // Dropped on a specific hour slot
681
+ newStart = setHours(setMinutes(date, 0), hour)
682
+ } else {
683
+ // Dropped on a day (keep original time)
684
+ const originalHours = draggedEvent.start.getHours()
685
+ const originalMinutes = draggedEvent.start.getMinutes()
686
+ newStart = setHours(setMinutes(date, originalMinutes), originalHours)
687
+ }
633
688
 
634
- // Calculate new start and end dates based on drop position
635
- // This is a simplified version - you'd need to implement proper date calculation
636
- // based on the view and drop position
637
- const newStart = new Date(result.destination.droppableId)
689
+ // Keep the same duration
638
690
  const duration = differenceInMinutes(draggedEvent.end, draggedEvent.start)
639
- const newEnd = addMinutes(newStart, duration)
691
+ newEnd = addMinutes(newStart, duration)
640
692
 
693
+ // Update event
641
694
  onEventDrop(draggedEvent.id, newStart, newEnd)
642
- }, [draggedEvent, onEventDrop])
695
+
696
+ // Reset drag state
697
+ setDraggedEventId(null)
698
+ setDragOverInfo(null)
699
+ setIsDragging(false)
700
+ }, [events, onEventDrop])
701
+
702
+ // Handle drag end (cleanup)
703
+ const handleDragEnd = useCallback(() => {
704
+ setDraggedEventId(null)
705
+ setDragOverInfo(null)
706
+ setIsDragging(false)
707
+ }, [])
643
708
 
644
709
  // Export calendar
645
710
  const exportCalendar = useCallback((format: 'ics' | 'csv' | 'json') => {
@@ -721,7 +786,15 @@ END:VCALENDAR`
721
786
  <div className="flex-1 overflow-auto">
722
787
  <div className="min-h-full">
723
788
  {/* All day events */}
724
- <div className="border-b p-2">
789
+ <div
790
+ className={cn(
791
+ "border-b p-2",
792
+ dragOverInfo?.date && !dragOverInfo?.hour && isSameDay(dragOverInfo.date, currentDate) && "bg-primary/10"
793
+ )}
794
+ onDragOver={(e) => handleDragOver(e, currentDate)}
795
+ onDragLeave={handleDragLeave}
796
+ onDrop={(e) => handleDrop(e, currentDate)}
797
+ >
725
798
  <div className="text-xs text-muted-foreground mb-1">All Day</div>
726
799
  <div className="space-y-1">
727
800
  {dayEvents
@@ -729,10 +802,14 @@ END:VCALENDAR`
729
802
  .map(event => (
730
803
  <div
731
804
  key={event.id}
805
+ draggable={allowEventDragging}
806
+ onDragStart={(e) => handleDragStart(e, event)}
807
+ onDragEnd={handleDragEnd}
732
808
  className="p-2 rounded text-xs cursor-pointer hover:opacity-80"
733
809
  style={{
734
810
  backgroundColor: event.color || eventColors[event.category || ''] || '#3b82f6',
735
- color: '#ffffff'
811
+ color: '#ffffff',
812
+ opacity: draggedEventId === event.id ? 0.5 : 1
736
813
  }}
737
814
  onClick={(e) => handleEventClick(event, e)}
738
815
  >
@@ -750,7 +827,13 @@ END:VCALENDAR`
750
827
  {format(setHours(new Date(), hour), timeFormat === '12h' ? 'h a' : 'HH:00')}
751
828
  </div>
752
829
  <div
753
- className="flex-1 relative border-l cursor-pointer hover:bg-muted/20"
830
+ className={cn(
831
+ "flex-1 relative border-l cursor-pointer hover:bg-muted/20",
832
+ dragOverInfo?.date && dragOverInfo?.hour === hour && isSameDay(dragOverInfo.date, currentDate) && "bg-primary/10"
833
+ )}
834
+ onDragOver={(e) => handleDragOver(e, currentDate, hour)}
835
+ onDragLeave={handleDragLeave}
836
+ onDrop={(e) => handleDrop(e, currentDate, hour)}
754
837
  onClick={() => {
755
838
  if (allowEventCreation) {
756
839
  const clickedTime = setHours(setMinutes(currentDate, 0), hour)
@@ -783,13 +866,18 @@ END:VCALENDAR`
783
866
  return (
784
867
  <div
785
868
  key={event.id}
869
+ draggable={allowEventDragging}
870
+ onDragStart={(e) => handleDragStart(e, event)}
871
+ onDragEnd={handleDragEnd}
786
872
  className="absolute left-0 right-0 mx-1 p-1 rounded text-xs cursor-pointer hover:opacity-80 overflow-hidden"
787
873
  style={{
788
874
  top: `${top}px`,
789
875
  height: `${height}px`,
790
876
  backgroundColor: event.color || eventColors[event.category || ''] || '#3b82f6',
791
877
  color: '#ffffff',
792
- zIndex: 10
878
+ zIndex: 10,
879
+ opacity: draggedEventId === event.id ? 0.5 : 1,
880
+ cursor: allowEventDragging ? 'move' : 'pointer'
793
881
  }}
794
882
  onClick={(e) => {
795
883
  e.stopPropagation()
@@ -860,14 +948,17 @@ END:VCALENDAR`
860
948
  const dayEvents = eventsInView.filter(event =>
861
949
  isSameDay(new Date(event.start), day) && !event.allDay
862
950
  )
863
-
864
951
  return (
865
952
  <div
866
953
  key={day.toISOString()}
867
954
  className={cn(
868
955
  "flex-1 relative border-l border-b cursor-pointer hover:bg-muted/20",
869
- isToday(day) && "bg-primary/5"
956
+ isToday(day) && "bg-primary/5",
957
+ dragOverInfo?.date && dragOverInfo?.hour === hour && isSameDay(dragOverInfo.date, day) && "bg-primary/10"
870
958
  )}
959
+ onDragOver={(e) => handleDragOver(e, day, hour)}
960
+ onDragLeave={handleDragLeave}
961
+ onDrop={(e) => handleDrop(e, day, hour)}
871
962
  onClick={() => {
872
963
  if (allowEventCreation) {
873
964
  const clickedTime = setHours(setMinutes(day, 0), hour)
@@ -898,13 +989,18 @@ END:VCALENDAR`
898
989
  return (
899
990
  <div
900
991
  key={event.id}
992
+ draggable={allowEventDragging}
993
+ onDragStart={(e) => handleDragStart(e, event)}
994
+ onDragEnd={handleDragEnd}
901
995
  className="absolute left-0 right-0 mx-1 p-1 rounded text-xs cursor-pointer hover:opacity-80 overflow-hidden"
902
996
  style={{
903
997
  top: `${top}px`,
904
998
  height: `${height}px`,
905
999
  backgroundColor: event.color || eventColors[event.category || ''] || '#3b82f6',
906
1000
  color: '#ffffff',
907
- zIndex: 10
1001
+ zIndex: 10,
1002
+ opacity: draggedEventId === event.id ? 0.5 : 1,
1003
+ cursor: allowEventDragging ? 'move' : 'pointer'
908
1004
  }}
909
1005
  onClick={(e) => {
910
1006
  e.stopPropagation()
@@ -971,8 +1067,12 @@ END:VCALENDAR`
971
1067
  "min-h-[100px] p-2 border rounded-lg cursor-pointer transition-colors",
972
1068
  !isCurrentMonth && "opacity-50",
973
1069
  isToday(day) && "bg-primary/10 border-primary",
974
- "hover:bg-muted/50"
1070
+ "hover:bg-muted/50",
1071
+ dragOverInfo?.date && !dragOverInfo?.hour && isSameDay(dragOverInfo.date, day) && "bg-primary/20 border-primary"
975
1072
  )}
1073
+ onDragOver={(e) => handleDragOver(e, day)}
1074
+ onDragLeave={handleDragLeave}
1075
+ onDrop={(e) => handleDrop(e, day)}
976
1076
  onClick={() => handleDateSelect(day)}
977
1077
  >
978
1078
  <div className={cn(
@@ -986,10 +1086,15 @@ END:VCALENDAR`
986
1086
  return (
987
1087
  <motion.div
988
1088
  key={event.id}
1089
+ draggable={allowEventDragging}
1090
+ onDragStart={(e) => handleDragStart(e, event)}
1091
+ onDragEnd={handleDragEnd}
989
1092
  className="text-xs p-1 rounded cursor-pointer truncate"
990
1093
  style={{
991
1094
  backgroundColor: event.color || eventColors[event.category || ''] || '#3b82f6',
992
- color: '#ffffff'
1095
+ color: '#ffffff',
1096
+ opacity: draggedEventId === event.id ? 0.5 : 1,
1097
+ cursor: allowEventDragging ? 'move' : 'pointer'
993
1098
  }}
994
1099
  whileHover={{ scale: 1.02 }}
995
1100
  whileTap={{ scale: 0.98 }}
@@ -0,0 +1,413 @@
1
+ import { GitHubRepository, GitHubStats, GitHubActivity, LanguageStats, RateLimitInfo, StarHistory } from "./types"
2
+
3
+ // Cache management
4
+ const cache = new Map<string, { data: any; timestamp: number; expiresAt: number }>()
5
+
6
+ // Language colors
7
+ export const LANGUAGE_COLORS: Record<string, string> = {
8
+ JavaScript: "#f7df1e",
9
+ TypeScript: "#3178c6",
10
+ Python: "#3776ab",
11
+ Java: "#ed8b00",
12
+ "C++": "#00599c",
13
+ "C#": "#239120",
14
+ Go: "#00add8",
15
+ Rust: "#000000",
16
+ Swift: "#fa7343",
17
+ Kotlin: "#7f52ff",
18
+ PHP: "#777bb4",
19
+ Ruby: "#cc342d",
20
+ HTML: "#e34f26",
21
+ CSS: "#1572b6",
22
+ Vue: "#4fc08d",
23
+ React: "#61dafb",
24
+ Shell: "#89e051",
25
+ Dart: "#0175c2",
26
+ Elixir: "#6e4a7e",
27
+ Scala: "#c22d40",
28
+ R: "#198ce7",
29
+ Julia: "#9558b2",
30
+ Lua: "#000080",
31
+ Perl: "#39457e",
32
+ Haskell: "#5e5086",
33
+ Clojure: "#db5855",
34
+ Erlang: "#b83998",
35
+ Objective_C: "#438eff",
36
+ // Add more as needed
37
+ }
38
+
39
+ // API base URL
40
+ const API_BASE = "https://api.github.com"
41
+
42
+ // Helper to make authenticated requests
43
+ async function githubFetch(url: string, token?: string): Promise<Response> {
44
+ const headers: HeadersInit = {
45
+ Accept: "application/vnd.github.v3+json",
46
+ }
47
+
48
+ if (token) {
49
+ headers.Authorization = `token ${token}`
50
+ }
51
+
52
+ const response = await fetch(url, { headers })
53
+
54
+ if (response.status === 403 && response.headers.get("X-RateLimit-Remaining") === "0") {
55
+ const resetTime = parseInt(response.headers.get("X-RateLimit-Reset") || "0") * 1000
56
+ const resetDate = new Date(resetTime)
57
+ throw new Error(`GitHub API rate limit exceeded. Resets at ${resetDate.toLocaleTimeString()}`)
58
+ }
59
+
60
+ if (!response.ok) {
61
+ throw new Error(`GitHub API error: ${response.status} ${response.statusText}`)
62
+ }
63
+
64
+ return response
65
+ }
66
+
67
+ // Get rate limit info
68
+ export async function getRateLimitInfo(token?: string): Promise<RateLimitInfo> {
69
+ const cacheKey = `rate-limit-${token || "public"}`
70
+ const cached = cache.get(cacheKey)
71
+
72
+ if (cached && cached.expiresAt > Date.now()) {
73
+ return cached.data
74
+ }
75
+
76
+ const response = await githubFetch(`${API_BASE}/rate_limit`, token)
77
+ const data = await response.json()
78
+
79
+ const rateLimitInfo: RateLimitInfo = {
80
+ limit: data.rate.limit,
81
+ remaining: data.rate.remaining,
82
+ reset: data.rate.reset * 1000,
83
+ used: data.rate.used,
84
+ }
85
+
86
+ cache.set(cacheKey, {
87
+ data: rateLimitInfo,
88
+ timestamp: Date.now(),
89
+ expiresAt: Date.now() + 60000, // Cache for 1 minute
90
+ })
91
+
92
+ return rateLimitInfo
93
+ }
94
+
95
+ // Fetch user repositories
96
+ export async function fetchUserRepositories(
97
+ username: string,
98
+ token?: string,
99
+ options?: {
100
+ sort?: string
101
+ per_page?: number
102
+ page?: number
103
+ }
104
+ ): Promise<GitHubRepository[]> {
105
+ const params = new URLSearchParams({
106
+ sort: options?.sort || "updated",
107
+ per_page: String(options?.per_page || 100),
108
+ page: String(options?.page || 1),
109
+ })
110
+
111
+ const cacheKey = `repos-${username}-${params.toString()}`
112
+ const cached = cache.get(cacheKey)
113
+
114
+ if (cached && cached.expiresAt > Date.now()) {
115
+ return cached.data
116
+ }
117
+
118
+ const response = await githubFetch(`${API_BASE}/users/${username}/repos?${params}`, token)
119
+ const repos = await response.json()
120
+
121
+ cache.set(cacheKey, {
122
+ data: repos,
123
+ timestamp: Date.now(),
124
+ expiresAt: Date.now() + 300000, // Cache for 5 minutes
125
+ })
126
+
127
+ return repos
128
+ }
129
+
130
+ // Fetch single repository
131
+ export async function fetchRepository(
132
+ owner: string,
133
+ repo: string,
134
+ token?: string
135
+ ): Promise<GitHubRepository> {
136
+ const cacheKey = `repo-${owner}-${repo}`
137
+ const cached = cache.get(cacheKey)
138
+
139
+ if (cached && cached.expiresAt > Date.now()) {
140
+ return cached.data
141
+ }
142
+
143
+ const response = await githubFetch(`${API_BASE}/repos/${owner}/${repo}`, token)
144
+ const repository = await response.json()
145
+
146
+ cache.set(cacheKey, {
147
+ data: repository,
148
+ timestamp: Date.now(),
149
+ expiresAt: Date.now() + 300000, // Cache for 5 minutes
150
+ })
151
+
152
+ return repository
153
+ }
154
+
155
+ // Fetch repository contributors count
156
+ export async function fetchContributorsCount(
157
+ owner: string,
158
+ repo: string,
159
+ token?: string
160
+ ): Promise<number> {
161
+ const cacheKey = `contributors-${owner}-${repo}`
162
+ const cached = cache.get(cacheKey)
163
+
164
+ if (cached && cached.expiresAt > Date.now()) {
165
+ return cached.data
166
+ }
167
+
168
+ try {
169
+ const response = await githubFetch(
170
+ `${API_BASE}/repos/${owner}/${repo}/contributors?per_page=1&anon=true`,
171
+ token
172
+ )
173
+
174
+ // Get total count from Link header
175
+ const linkHeader = response.headers.get("Link")
176
+ if (linkHeader) {
177
+ const match = linkHeader.match(/page=(\d+)>; rel="last"/)
178
+ if (match) {
179
+ const count = parseInt(match[1])
180
+ cache.set(cacheKey, {
181
+ data: count,
182
+ timestamp: Date.now(),
183
+ expiresAt: Date.now() + 3600000, // Cache for 1 hour
184
+ })
185
+ return count
186
+ }
187
+ }
188
+
189
+ // If no pagination, count the results
190
+ const contributors = await response.json()
191
+ const count = contributors.length
192
+
193
+ cache.set(cacheKey, {
194
+ data: count,
195
+ timestamp: Date.now(),
196
+ expiresAt: Date.now() + 3600000, // Cache for 1 hour
197
+ })
198
+
199
+ return count
200
+ } catch (error) {
201
+ console.error("Failed to fetch contributors:", error)
202
+ return 0
203
+ }
204
+ }
205
+
206
+ // Fetch repository star history (limited without token)
207
+ export async function fetchStarHistory(
208
+ owner: string,
209
+ repo: string,
210
+ token?: string
211
+ ): Promise<StarHistory[]> {
212
+ const cacheKey = `star-history-${owner}-${repo}`
213
+ const cached = cache.get(cacheKey)
214
+
215
+ if (cached && cached.expiresAt > Date.now()) {
216
+ return cached.data
217
+ }
218
+
219
+ try {
220
+ // This is a simplified version - for full star history you'd need
221
+ // to use the stargazers API with Accept: application/vnd.github.v3.star+json
222
+ // header and paginate through all results
223
+ const response = await githubFetch(`${API_BASE}/repos/${owner}/${repo}`, token)
224
+ const repoData = await response.json()
225
+
226
+ // For now, return current count as single data point
227
+ const history: StarHistory[] = [{
228
+ date: new Date().toISOString(),
229
+ count: repoData.stargazers_count,
230
+ repository: repoData.full_name,
231
+ }]
232
+
233
+ cache.set(cacheKey, {
234
+ data: history,
235
+ timestamp: Date.now(),
236
+ expiresAt: Date.now() + 3600000, // Cache for 1 hour
237
+ })
238
+
239
+ return history
240
+ } catch (error) {
241
+ console.error("Failed to fetch star history:", error)
242
+ return []
243
+ }
244
+ }
245
+
246
+ // Calculate repository statistics
247
+ export function calculateStats(repositories: GitHubRepository[]): GitHubStats {
248
+ const totalStars = repositories.reduce((sum, repo) => sum + repo.stargazers_count, 0)
249
+ const totalForks = repositories.reduce((sum, repo) => sum + repo.forks_count, 0)
250
+ const totalWatchers = repositories.reduce((sum, repo) => sum + repo.watchers_count, 0)
251
+ const totalIssues = repositories.reduce((sum, repo) => sum + repo.open_issues_count, 0)
252
+
253
+ const avgStarsPerRepo = repositories.length > 0 ? totalStars / repositories.length : 0
254
+
255
+ const mostStarredRepo = repositories.reduce((max, repo) =>
256
+ repo.stargazers_count > (max?.stargazers_count || 0) ? repo : max
257
+ , null as GitHubRepository | null)
258
+
259
+ // Calculate language statistics
260
+ const languageMap = new Map<string, number>()
261
+ repositories.forEach(repo => {
262
+ if (repo.language) {
263
+ languageMap.set(repo.language, (languageMap.get(repo.language) || 0) + 1)
264
+ }
265
+ })
266
+
267
+ const totalRepos = repositories.length
268
+ const languages: LanguageStats[] = Array.from(languageMap.entries())
269
+ .map(([language, count]) => ({
270
+ language,
271
+ count,
272
+ percentage: (count / totalRepos) * 100,
273
+ color: LANGUAGE_COLORS[language] || "#6b7280",
274
+ }))
275
+ .sort((a, b) => b.count - a.count)
276
+
277
+ // Generate recent activity (mock for now)
278
+ const recentActivity: GitHubActivity[] = repositories
279
+ .slice(0, 5)
280
+ .map(repo => ({
281
+ type: "star" as const,
282
+ repository: repo.full_name,
283
+ timestamp: repo.updated_at,
284
+ description: `Repository updated`,
285
+ }))
286
+
287
+ return {
288
+ totalStars,
289
+ totalForks,
290
+ totalWatchers,
291
+ totalIssues,
292
+ avgStarsPerRepo,
293
+ mostStarredRepo,
294
+ recentActivity,
295
+ languages,
296
+ }
297
+ }
298
+
299
+ // Format numbers for display
300
+ export function formatNumber(num: number): string {
301
+ if (num >= 1000000) {
302
+ return (num / 1000000).toFixed(1) + "M"
303
+ }
304
+ if (num >= 1000) {
305
+ return (num / 1000).toFixed(1) + "k"
306
+ }
307
+ return num.toString()
308
+ }
309
+
310
+ // Format date for display
311
+ export function formatDate(dateString: string): string {
312
+ const date = new Date(dateString)
313
+ const now = new Date()
314
+ const diffMs = now.getTime() - date.getTime()
315
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
316
+
317
+ if (diffDays === 0) {
318
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
319
+ if (diffHours === 0) {
320
+ const diffMinutes = Math.floor(diffMs / (1000 * 60))
321
+ return `${diffMinutes} minutes ago`
322
+ }
323
+ return `${diffHours} hours ago`
324
+ }
325
+
326
+ if (diffDays === 1) return "yesterday"
327
+ if (diffDays < 7) return `${diffDays} days ago`
328
+ if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`
329
+ if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`
330
+
331
+ return date.toLocaleDateString("en-US", {
332
+ year: "numeric",
333
+ month: "short",
334
+ day: "numeric",
335
+ })
336
+ }
337
+
338
+ // Clear cache
339
+ export function clearCache(pattern?: string): void {
340
+ if (pattern) {
341
+ for (const key of cache.keys()) {
342
+ if (key.includes(pattern)) {
343
+ cache.delete(key)
344
+ }
345
+ }
346
+ } else {
347
+ cache.clear()
348
+ }
349
+ }
350
+
351
+ // Export data as JSON
352
+ export function exportData(data: any, filename: string): void {
353
+ const jsonString = JSON.stringify(data, null, 2)
354
+ const blob = new Blob([jsonString], { type: "application/json" })
355
+ const url = URL.createObjectURL(blob)
356
+
357
+ const link = document.createElement("a")
358
+ link.href = url
359
+ link.download = filename
360
+ document.body.appendChild(link)
361
+ link.click()
362
+ document.body.removeChild(link)
363
+
364
+ URL.revokeObjectURL(url)
365
+ }
366
+
367
+ // Export data as CSV
368
+ export function exportAsCSV(repositories: GitHubRepository[], filename: string): void {
369
+ const headers = [
370
+ "Name",
371
+ "Owner",
372
+ "Stars",
373
+ "Forks",
374
+ "Watchers",
375
+ "Issues",
376
+ "Language",
377
+ "Description",
378
+ "URL",
379
+ "Created",
380
+ "Updated",
381
+ ]
382
+
383
+ const rows = repositories.map(repo => [
384
+ repo.name,
385
+ repo.owner.login,
386
+ repo.stargazers_count,
387
+ repo.forks_count,
388
+ repo.watchers_count,
389
+ repo.open_issues_count,
390
+ repo.language || "",
391
+ repo.description || "",
392
+ repo.html_url,
393
+ new Date(repo.created_at).toLocaleDateString(),
394
+ new Date(repo.updated_at).toLocaleDateString(),
395
+ ])
396
+
397
+ const csvContent = [
398
+ headers.join(","),
399
+ ...rows.map(row => row.map(cell => `"${cell}"`).join(",")),
400
+ ].join("\n")
401
+
402
+ const blob = new Blob([csvContent], { type: "text/csv" })
403
+ const url = URL.createObjectURL(blob)
404
+
405
+ const link = document.createElement("a")
406
+ link.href = url
407
+ link.download = filename
408
+ document.body.appendChild(link)
409
+ link.click()
410
+ document.body.removeChild(link)
411
+
412
+ URL.revokeObjectURL(url)
413
+ }