-
-
Notifications
You must be signed in to change notification settings - Fork 61
Add comprehensive analytics dashboard with interactive visualizations #111
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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
+60
to
+83
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cache is effectively broken: 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 |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const netRaised = project.sold_counter - project.refund_counter; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const goalPercentage = (netRaised / project.maximum_amount) * 100; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const goalPercentage = (netRaised / project.maximum_amount) * 100; | |
| const goalPercentage = project.maximum_amount > 0 ? (netRaised / project.maximum_amount) * 100 : 0; |
Copilot
AI
Dec 13, 2025
There was a problem hiding this comment.
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.
| 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), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Copilot
AI
Dec 13, 2025
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Copilot
AI
Dec 13, 2025
There was a problem hiding this comment.
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
AI
Dec 13, 2025
There was a problem hiding this comment.
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.
| const duration = m.deadline - m.createdAt; | |
| const duration = Math.max(0, m.deadline - m.createdAt); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Copilot
AI
Dec 13, 2025
There was a problem hiding this comment.
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.
| 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(',') |
| 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) | ||
| ); |
There was a problem hiding this comment.
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.