Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
319 changes: 319 additions & 0 deletions src/lib/analytics/datacollector.ts
Original file line number Diff line number Diff line change
@@ -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<string, ProjectMetrics> = 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;
Comment on lines +80 to +81
Copy link

Copilot AI Dec 13, 2025

Choose a reason for hiding this comment

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

The cache check uses a global lastUpdate timestamp but checks individual project cache entries. This means if one project is cached recently, the cache for all projects will appear valid even if they haven't been individually updated. Consider checking the cache validity per-project or updating lastUpdate when caching individual projects.

Copilot uses AI. Check for mistakes.
}

Comment on lines +60 to +83
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Cache is effectively broken: lastUpdate is never set, and cache validity is global (not per project).
(now - this.lastUpdate) < CACHE_DURATION will almost always be false (since lastUpdate stays 0), and even if fixed, one timestamp for all entries will let stale entries “ride along” with fresher ones.

 export class AnalyticsDataCollector {
     private static instance: AnalyticsDataCollector;
-    private metricsCache: Map<string, ProjectMetrics> = new Map();
-    private timeSeriesCache: TimeSeriesData[] = [];
-    private lastUpdate: number = 0;
-    private readonly CACHE_DURATION = 60000; // 1 minute
+    private metricsCache: Map<string, { metrics: ProjectMetrics; updatedAt: number }> = new Map();
+    private timeSeriesCache: { data: TimeSeriesData[]; updatedAt: number } = { data: [], updatedAt: 0 };
+    private createdAtCache: Map<string, number> = new Map();
+    private readonly CACHE_DURATION_MS = 60000; // 1 minute

 ...
     collectProjectMetrics(project: Project): ProjectMetrics {
-        const cachedMetrics = this.metricsCache.get(project.project_id);
+        const cached = this.metricsCache.get(project.project_id);
         const now = Date.now();

-        if (cachedMetrics && (now - this.lastUpdate) < this.CACHE_DURATION) {
-            return cachedMetrics;
+        if (cached && (now - cached.updatedAt) < this.CACHE_DURATION_MS) {
+            return cached.metrics;
         }
 ...
-        this.metricsCache.set(project.project_id, metrics);
+        this.metricsCache.set(project.project_id, { metrics, updatedAt: now });
         return metrics;
     }

 ...
     clearCache(): void {
         this.metricsCache.clear();
-        this.timeSeriesCache = [];
-        this.lastUpdate = 0;
+        this.timeSeriesCache = { data: [], updatedAt: 0 };
+        this.createdAtCache.clear();
     }
 }

Also applies to: 119-121, 312-316

🤖 Prompt for AI Agents
In src/lib/analytics/datacollector.ts around lines 60 to 83, the cache logic is
broken because lastUpdate is a single global timestamp that is never set and
therefore (now - this.lastUpdate) is meaningless and also allows stale project
entries to be considered fresh; fix by using per-project timestamps (either
change metricsCache to store an object {metrics, updatedAt} or introduce a
parallel Map<string, number> for timestamps), set the project's timestamp to
Date.now() when you populate the cache, and change the validity check to use
that per-project timestamp ((now - entry.updatedAt) < CACHE_DURATION). Apply the
same per-entry timestamp fix to the other affected locations (lines 119-121 and
312-316).

const netRaised = project.sold_counter - project.refund_counter;
const goalPercentage = (netRaised / project.maximum_amount) * 100;
Copy link

Copilot AI Dec 13, 2025

Choose a reason for hiding this comment

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

Division by zero risk: if project.maximum_amount is zero, this calculation will result in Infinity or NaN. Add a check to handle the case where maximum_amount is zero or use a safe division helper function.

Suggested change
const goalPercentage = (netRaised / project.maximum_amount) * 100;
const goalPercentage = project.maximum_amount > 0 ? (netRaised / project.maximum_amount) * 100 : 0;

Copilot uses AI. Check for mistakes.
const isEnded = project.is_timestamp_limit
? project.block_limit < Date.now()
: false; // Would need current block height
Comment thread
khush196 marked this conversation as resolved.

const isSuccessful = netRaised >= project.minimum_amount && isEnded;
Comment thread
khush196 marked this conversation as resolved.
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
Copy link

Copilot AI Dec 13, 2025

Choose a reason for hiding this comment

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

The createdAt timestamp uses a random value instead of actual creation data, which will produce inconsistent results across multiple calls and make analytics unreliable. This should either use actual project creation timestamps from the blockchain or a deterministic fallback based on project data.

Suggested change
createdAt: Date.now() - (Math.random() * 30 * 24 * 60 * 60 * 1000), // Estimate
// Use project.created_at if available, otherwise fallback to project.block_limit or 0
createdAt: (project.created_at ?? project.block_limit ?? 0),

Copilot uses AI. Check for mistakes.
};
Comment on lines +116 to +117
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

createdAt is randomized per compute, which breaks time-series, “recently funded”, and reproducibility.
Because createdAt feeds generateTimeSeriesData() and recentlyFunded (from src/lib/common/analytics-store.ts Lines 15-20), using Math.random() makes daily buckets and ordering unstable across refreshes.

-        const metrics: ProjectMetrics = {
+        const createdAt =
+            this.createdAtCache.get(project.project_id) ??
+            Date.now(); // TODO: replace with real project creation time
+        this.createdAtCache.set(project.project_id, createdAt);
+
+        const metrics: ProjectMetrics = {
             ...
-            createdAt: Date.now() - (Math.random() * 30 * 24 * 60 * 60 * 1000), // Estimate
+            createdAt,
         };

Even as an estimate, it should be stable (per project) within and across sessions.

Also applies to: 147-160, 15-20


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));
}
Comment on lines +123 to +129
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Contributor count estimator inflates: returns >= 1 even with zero contributions.
This will overstate contributors platform-wide and per-project.

-        return Math.max(1, Math.floor(netTransactions / averageContribution));
+        if (netTransactions <= 0) return 0;
+        return Math.max(1, Math.floor(netTransactions / averageContribution));
🤖 Prompt for AI Agents
In src/lib/analytics/datacollector.ts around lines 123 to 129, the estimator
currently forces a minimum of 1 contributor which incorrectly inflates counts
when there are zero or negative net transactions; change the logic to allow zero
contributors by replacing the Math.max(1, ...) floor with Math.max(0, ...) and
ensure you compute netTransactions and averageContribution safely (coerce to
numbers, avoid division by zero) so the function returns 0 when netTransactions
<= 0 and a non-negative integer otherwise.


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;
});
Comment on lines +157 to +160
Copy link

Copilot AI Dec 13, 2025

Choose a reason for hiding this comment

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

Date comparison using toLocaleDateString() is locale-dependent and may produce inconsistent results across different locales or time zones. This can lead to incorrect counting of new projects. Use a consistent date comparison method like comparing YYYY-MM-DD strings formatted with toISOString() or comparing date parts directly.

Copilot uses AI. Check for mistakes.

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;
Comment on lines +142 to +180
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Daily bucketing uses toLocaleDateString() (timezone/locale dependent) and string equality for grouping.
This will shift day boundaries by user locale/timezone and can mis-bucket around midnight/DST. Prefer a stable UTC key (YYYY-MM-DD).

-            const date = new Date(timestamp).toLocaleDateString();
+            const date = new Date(timestamp).toISOString().slice(0, 10);

...
-                const createdDate = new Date(m.createdAt).toLocaleDateString();
+                const createdDate = new Date(m.createdAt).toISOString().slice(0, 10);
                 return createdDate === date;
🤖 Prompt for AI Agents
In src/lib/analytics/datacollector.ts around lines 142 to 180, replace the
locale-dependent toLocaleDateString() grouping with a stable UTC day key
(YYYY-MM-DD) and use UTC-day boundaries for comparisons: compute a helper
getUTCDateKey(ts) that returns new Date(ts).toISOString().slice(0,10), use that
key for newProjects grouping and for any equality comparisons, and when checking
whether a project is active on a given day derive the day's UTC start and end
timestamps (e.g. parse key to midnight UTC and add 24h) and compare
m.createdAt/deadline against those UTC start/end bounds (or compare project date
keys against the loop day key) so bucket boundaries are consistent across
timezones and DST.

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;
Comment on lines +198 to +203
Copy link

Copilot AI Dec 13, 2025

Choose a reason for hiding this comment

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

Potential empty array error: if data has fewer than 7 items, data.slice(-7) may return fewer than 7 items, and data.slice(-14, -7) may return an empty array or have fewer items, which could affect trend calculation accuracy. When olderData is empty, the trend is always calculated as 'stable' regardless of actual funding changes. Add validation to ensure sufficient data points exist before calculating trends.

Copilot uses AI. Check for mistakes.

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;
Copy link

Copilot AI Dec 13, 2025

Choose a reason for hiding this comment

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

Potential negative duration: if m.deadline is less than m.createdAt (which could happen given that createdAt uses random values), the duration will be negative, producing incorrect average time to goal calculations. Add validation to ensure deadline >= createdAt or handle negative durations appropriately.

Suggested change
const duration = m.deadline - m.createdAt;
const duration = Math.max(0, m.deadline - m.createdAt);

Copilot uses AI. Check for mistakes.
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);
}
Comment on lines +269 to +296
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

CSV export needs proper escaping + CSV injection mitigation.
Current logic only quotes strings containing commas, and doesn’t escape quotes/newlines; also spreadsheet formula injection is possible with values starting = + - @.

-    private convertToCSV(data: any): string {
+    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(',')
-        );
+        const escapeCell = (v: unknown) => {
+            let s = String(v ?? '');
+            // Mitigate CSV injection in spreadsheet apps
+            if (/^[=+\-@]/.test(s)) s = `'${s}`;
+            // Escape quotes
+            s = s.replace(/"/g, '""');
+            // Quote if needed
+            if (/[",\n\r]/.test(s)) s = `"${s}"`;
+            return s;
+        };
+        const rows = projects.map((p: any) => Object.values(p).map(escapeCell).join(','));
 
         return [headers, ...rows].join('\n');
     }

Also applies to: 298-310

🤖 Prompt for AI Agents
In src/lib/analytics/datacollector.ts around lines 269-296 (and also apply to
the related CSV code at 298-310), the CSV export currently only quotes strings
with commas and fails to escape quotes/newlines and mitigate CSV/spreadsheet
formula injection; update the CSV conversion to (1) stringify every field, (2)
escape internal double quotes by doubling them, (3) wrap every field in double
quotes if it contains commas, quotes or newlines (safer: always wrap text
fields), (4) normalize/remove CR/LF inside fields (or replace with space), and
(5) mitigate formula injection by prefixing any field that begins with =, +, -,
or @ with a single quote (') before exporting. Ensure this escaping/mitigation
is applied for all fields (summary values, project fields, timeSeries, trends,
contributors) by centralizing the sanitizer used by convertToCSV and invoking it
wherever CSV is produced.


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(',')
Comment on lines +298 to +306
Copy link

Copilot AI Dec 13, 2025

Choose a reason for hiding this comment

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

CSV escaping is incomplete: the code only escapes commas in strings but doesn't handle other special characters like quotes, newlines, or carriage returns. A string value containing quotes could break the CSV format. Use proper CSV escaping that wraps all string values in quotes and escapes embedded quotes by doubling them.

Suggested change
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(',')
private escapeCSV(value: any): string {
if (value === null || value === undefined) return '';
const str = String(value);
// Escape double quotes by doubling them
const escaped = str.replace(/"/g, '""');
// Always wrap in double quotes if the value contains special characters or is a string
if (/[",\n\r]/.test(str) || typeof value === 'string') {
return `"${escaped}"`;
}
return str;
}
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 => this.escapeCSV(v)).join(',')

Copilot uses AI. Check for mistakes.
);

return [headers, ...rows].join('\n');
}

clearCache(): void {
this.metricsCache.clear();
this.timeSeriesCache = [];
this.lastUpdate = 0;
}
}

export const analyticsCollector = AnalyticsDataCollector.getInstance();
20 changes: 20 additions & 0 deletions src/lib/common/analytics-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { writable, derived } from 'svelte/store';
import type { ProjectMetrics, TimeSeriesData } from '$lib/analytics/datacollector';

export const analyticsMetrics = writable<ProjectMetrics[]>([]);
export const analyticsTimeSeries = writable<TimeSeriesData[]>([]);
export const analyticsLastUpdate = writable<number>(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)
);
Loading