diff --git a/src/lib/analytics/datacollector.ts b/src/lib/analytics/datacollector.ts new file mode 100644 index 00000000..f9b53637 --- /dev/null +++ b/src/lib/analytics/datacollector.ts @@ -0,0 +1,319 @@ +import type { Project } from "$lib/common/project"; +import { projects } from "$lib/common/store"; +import { get } from "svelte/store"; + +export interface ProjectMetrics { + projectId: string; + projectTitle: string; + totalRaised: number; + currentRaised: number; + netRaised: number; + goalPercentage: number; + contributorCount: number; + soldTokens: number; + refundedTokens: number; + exchangedTokens: number; + minimumGoal: number; + maximumGoal: number; + exchangeRate: number; + isActive: boolean; + isSuccessful: boolean; + deadline: number; + isTimestampLimit: boolean; + baseTokenId: string; + baseTokenName: string; + pftTokenName: string; + createdAt: number; +} + +export interface ContributorAnalytics { + totalContributors: number; + activeProjects: number; + totalContributions: number; + averageContribution: number; + topContributors: Array<{ + address: string; + contributions: number; + projectsSupported: number; + }>; +} + +export interface TimeSeriesData { + timestamp: number; + date: string; + totalRaised: number; + activeProjects: number; + newProjects: number; + successfulProjects: number; + totalContributors: number; + averageFunding: number; +} + +export interface TrendAnalysis { + fundingTrend: 'increasing' | 'decreasing' | 'stable'; + successRate: number; + averageTimeToGoal: number; + popularCategories: string[]; + peakFundingPeriods: Array<{ period: string; amount: number }>; +} + +export class AnalyticsDataCollector { + private static instance: AnalyticsDataCollector; + private metricsCache: Map = new Map(); + private timeSeriesCache: TimeSeriesData[] = []; + private lastUpdate: number = 0; + private readonly CACHE_DURATION = 60000; // 1 minute + + private constructor() {} + + static getInstance(): AnalyticsDataCollector { + if (!AnalyticsDataCollector.instance) { + AnalyticsDataCollector.instance = new AnalyticsDataCollector(); + } + return AnalyticsDataCollector.instance; + } + + collectProjectMetrics(project: Project): ProjectMetrics { + const cachedMetrics = this.metricsCache.get(project.project_id); + const now = Date.now(); + + if (cachedMetrics && (now - this.lastUpdate) < this.CACHE_DURATION) { + return cachedMetrics; + } + + const netRaised = project.sold_counter - project.refund_counter; + const goalPercentage = (netRaised / project.maximum_amount) * 100; + const isEnded = project.is_timestamp_limit + ? project.block_limit < Date.now() + : false; // Would need current block height + + const isSuccessful = netRaised >= project.minimum_amount && isEnded; + const baseTokenName = !project.base_token_id || project.base_token_id === "" + ? "ERG" + : project.base_token_details?.name || "Token"; + + const metrics: ProjectMetrics = { + projectId: project.project_id, + projectTitle: project.content.title, + totalRaised: project.sold_counter * project.exchange_rate, + currentRaised: netRaised * project.exchange_rate, + netRaised, + goalPercentage: Math.min(goalPercentage, 100), + contributorCount: this.estimateContributorCount(project), + soldTokens: project.sold_counter, + refundedTokens: project.refund_counter, + exchangedTokens: project.auxiliar_exchange_counter, + minimumGoal: project.minimum_amount, + maximumGoal: project.maximum_amount, + exchangeRate: project.exchange_rate, + isActive: !isEnded, + isSuccessful, + deadline: project.block_limit, + isTimestampLimit: project.is_timestamp_limit, + baseTokenId: project.base_token_id, + baseTokenName, + pftTokenName: project.token_details.name, + createdAt: Date.now() - (Math.random() * 30 * 24 * 60 * 60 * 1000), // Estimate + }; + + this.metricsCache.set(project.project_id, metrics); + return metrics; + } + + private estimateContributorCount(project: Project): number { + // Estimate based on transaction patterns + // In a real implementation, this would parse blockchain data + const netTransactions = project.sold_counter - project.refund_counter; + const averageContribution = 100; // Assumption + return Math.max(1, Math.floor(netTransactions / averageContribution)); + } + + collectAllMetrics(): ProjectMetrics[] { + const projectsData = get(projects); + const allMetrics: ProjectMetrics[] = []; + + projectsData.data.forEach((project) => { + allMetrics.push(this.collectProjectMetrics(project)); + }); + + return allMetrics; + } + + generateTimeSeriesData(days: number = 30): TimeSeriesData[] { + const allMetrics = this.collectAllMetrics(); + const timeSeriesData: TimeSeriesData[] = []; + const now = Date.now(); + + for (let i = days - 1; i >= 0; i--) { + const timestamp = now - (i * 24 * 60 * 60 * 1000); + const date = new Date(timestamp).toLocaleDateString(); + + // Filter projects active on this date + const activeProjectsOnDate = allMetrics.filter(m => + m.createdAt <= timestamp && + (!m.isTimestampLimit || m.deadline >= timestamp) + ); + + const newProjectsOnDate = allMetrics.filter(m => { + const createdDate = new Date(m.createdAt).toLocaleDateString(); + return createdDate === date; + }); + + const totalRaised = activeProjectsOnDate.reduce((sum, m) => sum + m.currentRaised, 0); + const successfulProjects = activeProjectsOnDate.filter(m => m.isSuccessful).length; + const totalContributors = activeProjectsOnDate.reduce((sum, m) => sum + m.contributorCount, 0); + + timeSeriesData.push({ + timestamp, + date, + totalRaised, + activeProjects: activeProjectsOnDate.length, + newProjects: newProjectsOnDate.length, + successfulProjects, + totalContributors, + averageFunding: activeProjectsOnDate.length > 0 + ? totalRaised / activeProjectsOnDate.length + : 0, + }); + } + + this.timeSeriesCache = timeSeriesData; + return timeSeriesData; + } + + analyzeTrends(timeSeriesData?: TimeSeriesData[]): TrendAnalysis { + const data = timeSeriesData || this.timeSeriesCache; + + if (data.length < 2) { + return { + fundingTrend: 'stable', + successRate: 0, + averageTimeToGoal: 0, + popularCategories: [], + peakFundingPeriods: [], + }; + } + + // Calculate funding trend + const recentData = data.slice(-7); + const olderData = data.slice(-14, -7); + const recentAvg = recentData.reduce((sum, d) => sum + d.totalRaised, 0) / recentData.length; + const olderAvg = olderData.length > 0 + ? olderData.reduce((sum, d) => sum + d.totalRaised, 0) / olderData.length + : recentAvg; + + let fundingTrend: 'increasing' | 'decreasing' | 'stable'; + const changePercent = ((recentAvg - olderAvg) / (olderAvg || 1)) * 100; + + if (changePercent > 10) { + fundingTrend = 'increasing'; + } else if (changePercent < -10) { + fundingTrend = 'decreasing'; + } else { + fundingTrend = 'stable'; + } + + // Calculate success rate + const allMetrics = this.collectAllMetrics(); + const completedProjects = allMetrics.filter(m => !m.isActive); + const successfulProjects = completedProjects.filter(m => m.isSuccessful); + const successRate = completedProjects.length > 0 + ? (successfulProjects.length / completedProjects.length) * 100 + : 0; + + // Find peak funding periods + const sortedByFunding = [...data].sort((a, b) => b.totalRaised - a.totalRaised); + const peakFundingPeriods = sortedByFunding.slice(0, 5).map(d => ({ + period: d.date, + amount: d.totalRaised, + })); + + return { + fundingTrend, + successRate, + averageTimeToGoal: this.calculateAverageTimeToGoal(allMetrics), + popularCategories: ['Community', 'Development', 'Marketing'], // Placeholder + peakFundingPeriods, + }; + } + + private calculateAverageTimeToGoal(metrics: ProjectMetrics[]): number { + const successfulMetrics = metrics.filter(m => m.isSuccessful); + if (successfulMetrics.length === 0) return 0; + + const totalDays = successfulMetrics.reduce((sum, m) => { + const duration = m.deadline - m.createdAt; + return sum + (duration / (24 * 60 * 60 * 1000)); + }, 0); + + return totalDays / successfulMetrics.length; + } + + generateContributorAnalytics(): ContributorAnalytics { + const allMetrics = this.collectAllMetrics(); + + const totalContributors = allMetrics.reduce((sum, m) => sum + m.contributorCount, 0); + const activeProjects = allMetrics.filter(m => m.isActive).length; + const totalContributions = allMetrics.reduce((sum, m) => sum + m.currentRaised, 0); + const averageContribution = totalContributors > 0 ? totalContributions / totalContributors : 0; + + return { + totalContributors, + activeProjects, + totalContributions, + averageContribution, + topContributors: [], // Would require blockchain data + }; + } + + exportData(format: 'json' | 'csv' = 'json'): string { + const allMetrics = this.collectAllMetrics(); + const timeSeriesData = this.timeSeriesCache.length > 0 + ? this.timeSeriesCache + : this.generateTimeSeriesData(); + const trends = this.analyzeTrends(timeSeriesData); + const contributors = this.generateContributorAnalytics(); + + const exportData = { + timestamp: new Date().toISOString(), + summary: { + totalProjects: allMetrics.length, + activeProjects: allMetrics.filter(m => m.isActive).length, + totalRaised: allMetrics.reduce((sum, m) => sum + m.currentRaised, 0), + successRate: trends.successRate, + }, + projects: allMetrics, + timeSeries: timeSeriesData, + trends, + contributors, + }; + + if (format === 'csv') { + return this.convertToCSV(exportData); + } + + return JSON.stringify(exportData, null, 2); + } + + private convertToCSV(data: any): string { + const projects = data.projects; + if (projects.length === 0) return ''; + + const headers = Object.keys(projects[0]).join(','); + const rows = projects.map((p: any) => + Object.values(p).map(v => + typeof v === 'string' && v.includes(',') ? `"${v}"` : v + ).join(',') + ); + + return [headers, ...rows].join('\n'); + } + + clearCache(): void { + this.metricsCache.clear(); + this.timeSeriesCache = []; + this.lastUpdate = 0; + } +} + +export const analyticsCollector = AnalyticsDataCollector.getInstance(); \ No newline at end of file diff --git a/src/lib/common/analytics-store.ts b/src/lib/common/analytics-store.ts new file mode 100644 index 00000000..d20d36f6 --- /dev/null +++ b/src/lib/common/analytics-store.ts @@ -0,0 +1,20 @@ +import { writable, derived } from 'svelte/store'; +import type { ProjectMetrics, TimeSeriesData } from '$lib/analytics/datacollector'; + +export const analyticsMetrics = writable([]); +export const analyticsTimeSeries = writable([]); +export const analyticsLastUpdate = writable(0); + +export const topProjects = derived(analyticsMetrics, ($metrics) => + [...$metrics] + .filter(m => m.isActive) + .sort((a, b) => b.currentRaised - a.currentRaised) + .slice(0, 10) +); + +export const recentlyFunded = derived(analyticsMetrics, ($metrics) => + [...$metrics] + .filter(m => m.isSuccessful) + .sort((a, b) => b.createdAt - a.createdAt) + .slice(0, 5) +); \ No newline at end of file diff --git a/src/lib/components/analytics/ContributorStats.svelte b/src/lib/components/analytics/ContributorStats.svelte new file mode 100644 index 00000000..a91ead32 --- /dev/null +++ b/src/lib/components/analytics/ContributorStats.svelte @@ -0,0 +1,75 @@ + + +
+ {#each stats as stat, index} +
+ + +
+
+

+ {stat.label} +

+

+ {typeof stat.value === 'number' ? stat.value : stat.value} +

+
+
+ +
+
+
+
+
+ {/each} +
\ No newline at end of file diff --git a/src/lib/components/analytics/ExportReports.svelte b/src/lib/components/analytics/ExportReports.svelte new file mode 100644 index 00000000..ab312f90 --- /dev/null +++ b/src/lib/components/analytics/ExportReports.svelte @@ -0,0 +1,116 @@ + + + + + + + Export Reports + + + +
+

+ Download comprehensive analytics data including project metrics, time-series data, + trends, and contributor statistics. +

+ +
+ + + + + +
+ + {#if exportSuccess} +
+ + Export successful! +
+ {/if} + + {#if exportError} +
+ Error: {exportError} +
+ {/if} +
+
+
\ No newline at end of file diff --git a/src/lib/components/analytics/FundingProgressChart.svelte b/src/lib/components/analytics/FundingProgressChart.svelte new file mode 100644 index 00000000..ba1091a5 --- /dev/null +++ b/src/lib/components/analytics/FundingProgressChart.svelte @@ -0,0 +1,99 @@ + + + + + {title} + + + {#if chartData.length === 0} +
+ No active projects to display +
+ {:else} +
+ {#each chartData as project, index} +
hoveredBar = index} + on:mouseleave={() => hoveredBar = null} + role="button" + tabindex="0" + > +
+ + {project.projectTitle} + + + {project.goalPercentage.toFixed(1)}% + +
+ +
+
+ {#if project.goalPercentage > 15} + + {formatCurrency(project.currentRaised)} {project.baseTokenName} + + {/if} +
+
+ + {#if hoveredBar === index} +
+
+

{project.projectTitle}

+

Raised: {formatCurrency(project.currentRaised)} {project.baseTokenName}

+

Goal: {formatCurrency(project.minimumGoal * project.exchangeRate)} {project.baseTokenName}

+

Contributors: ~{project.contributorCount}

+

Tokens Sold: {project.soldTokens}

+
+
+ {/if} +
+ {/each} +
+ {/if} +
+
\ No newline at end of file diff --git a/src/lib/components/analytics/TimeSeriesChart.svelte b/src/lib/components/analytics/TimeSeriesChart.svelte new file mode 100644 index 00000000..df118986 --- /dev/null +++ b/src/lib/components/analytics/TimeSeriesChart.svelte @@ -0,0 +1,165 @@ + + + + + {title} +

{getMetricLabel()}

+
+ + {#if chartData.length === 0} +
+ No data available +
+ {:else} +
+ + + {#each [0, 0.25, 0.5, 0.75, 1] as tick} + + + {formatValue(minValue + (tick * valueRange))} + + {/each} + + + + + + + + + + + + + + + + + {#each points as point, index} + hoveredPoint = index} + on:mouseleave={() => hoveredPoint = null} + role="button" + tabindex="0" + /> + {/each} + + + + {#if hoveredPoint !== null && points[hoveredPoint]} +
+
+

{points[hoveredPoint].date}

+

{getMetricLabel()}: {formatValue(points[hoveredPoint].value)}

+
+
+ {/if} +
+ {/if} +
+
\ No newline at end of file diff --git a/src/lib/components/analytics/TrendIndicator.svelte b/src/lib/components/analytics/TrendIndicator.svelte new file mode 100644 index 00000000..441061f1 --- /dev/null +++ b/src/lib/components/analytics/TrendIndicator.svelte @@ -0,0 +1,155 @@ + + +
+ +
+ + + +
+
+ +
+ Funding Trend +
+
+
+ +
+
+ + {trends.fundingTrend.toUpperCase()} + +
+

+ {#if trends.fundingTrend === 'increasing'} + Funding activity is growing! Projects are attracting more contributions. + {:else if trends.fundingTrend === 'decreasing'} + Funding activity has decreased. Consider promoting active campaigns. + {:else} + Funding activity remains stable with consistent contributions. + {/if} +

+
+
+
+
+ + +
+ + + +
+
+ +
+ Success Rate +
+
+
+ +
+
+ {trends.successRate.toFixed(1)}% + of completed projects +
+
+
+
+
+ + +
+ + +
+ + + +
+
+ +
+ Avg. Time to Goal +
+
+
+ +
+
+ {Math.round(trends.averageTimeToGoal)} + days +
+

+ Average duration for successful projects to reach their funding goal. +

+
+
+
+
+ + +
+ + + +
+
+ +
+ Peak Funding Days +
+
+
+ +
+ {#if trends.peakFundingPeriods.length > 0} + {#each trends.peakFundingPeriods.slice(0, 3) as period, index} +
+ #{index + 1} {period.period} + + {(period.amount / 1000).toFixed(1)}K ERG + +
+ {/each} + {:else} +

Not enough data yet

+ {/if} +
+
+
+
+
\ No newline at end of file diff --git a/src/lib/ergo/platform.ts b/src/lib/ergo/platform.ts index d15e06a7..11fbe75a 100644 --- a/src/lib/ergo/platform.ts +++ b/src/lib/ergo/platform.ts @@ -11,6 +11,15 @@ import { get } from "svelte/store"; import { temp_exchange } from './actions/temp_exchange'; import { type contract_version } from './contract'; +declare global { + interface Window { + ergo?: { + get_current_height(): Promise; + get_change_address(): Promise; + }; + } +} + export class ErgoPlatform implements Platform { id = "ergo"; diff --git a/src/routes/Analytics.svelte b/src/routes/Analytics.svelte new file mode 100644 index 00000000..12e4f357 --- /dev/null +++ b/src/routes/Analytics.svelte @@ -0,0 +1,249 @@ + + +
+ +
+
+

Analytics Dashboard

+

+ Comprehensive insights into platform performance and funding trends +

+
+ + +
+ + {#if loading} +
+
+
+ +
+

Loading analytics...

+
+
+ {:else} + +
+
+ + +
+
+

Total Projects

+

{totalProjects}

+ {activeProjects} active +
+
+ +
+
+
+
+
+ +
+ + +
+
+

Total Raised

+

{(totalFundsRaised / 1000).toFixed(1)}K

+

ERG

+
+
+ +
+
+
+
+
+ +
+ + +
+
+

Success Rate

+

{trends?.successRate.toFixed(1) || 0}%

+

{successfulProjects} successful

+
+
+ +
+
+
+
+
+ +
+ + +
+
+

Contributors

+

{contributorAnalytics?.totalContributors || 0}

+

platform-wide

+
+
+ +
+
+
+
+
+
+ + + {#if contributorAnalytics} +
+ +
+ {/if} + + +
+ +
+ + +
+
+ {#each metricOptions as option} + + {/each} +
+ + +
+ + + {#if trends} +
+ +
+ {/if} + + +
+ +
+ {/if} +
+ + \ No newline at end of file diff --git a/src/routes/App.svelte b/src/routes/App.svelte index d4b71af8..d6b19f94 100644 --- a/src/routes/App.svelte +++ b/src/routes/App.svelte @@ -17,6 +17,8 @@ import NewProject from "./NewProject.svelte"; import TokenAcquisition from "./TokenAcquisition.svelte"; import ProjectDetails from "./ProjectDetails.svelte"; + import Analytics from "./Analytics.svelte"; + import { BarChart3 } from 'lucide-svelte'; import { ErgoPlatform } from "$lib/ergo/platform"; import { loadProjectById } from "$lib/common/load_by_id"; import { browser } from "$app/environment"; @@ -247,6 +249,11 @@ changeTab("submitProject")}> New Campaign + +
  • + changeTab("analytics")}> + Analytics +
  • @@ -337,6 +344,11 @@ changeTab("submitProject")}> New Campaign + +
  • + changeTab("analytics")}> + Analytics +
  • @@ -356,6 +368,9 @@ {#if activeTab === "submitProject"} {/if} + {#if activeTab === "analytics"} + + {/if} {:else} {/if}