diff --git a/package-lock.json b/package-lock.json index 0b8e584de..d95757f1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "@web3-onboard/injected-wallets": "^2.10.17", "@web3-onboard/walletconnect": "^2.5.5", "bezier-easing": "^2.1.0", + "chart.js": "^4.5.1", "csv-simple-parser": "^1.0.3", "cupertino-pane": "^1.5.4", "ethereum-blockies-base64": "^1.0.2", @@ -4805,6 +4806,12 @@ "solid-js": "^1.8.8" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@lit-labs/ssr-dom-shim": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.4.0.tgz", @@ -15490,6 +15497,18 @@ "dev": true, "license": "MIT" }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", diff --git a/package.json b/package.json index 46ce1b3b0..e4fceff7e 100644 --- a/package.json +++ b/package.json @@ -82,12 +82,12 @@ "vitest": "^4.0.6" }, "dependencies": { - "@gelatocloud/gasless": "^0.0.12", "@apollo/client": "^3.11.8", "@drips-network/sdk": "0.1.0-alpha.15", "@efstajas/svelte-stored-writable": "^1.0.0", "@efstajas/versioned-parser": "^0.1.4", "@ethereum-attestation-service/eas-sdk": "^2.7.0", + "@gelatocloud/gasless": "^0.0.12", "@grafana/faro-web-sdk": "^1.18.1", "@grafana/faro-web-tracing": "^1.18.1", "@intercom/messenger-js-sdk": "^0.0.18", @@ -114,6 +114,7 @@ "@web3-onboard/injected-wallets": "^2.10.17", "@web3-onboard/walletconnect": "^2.5.5", "bezier-easing": "^2.1.0", + "chart.js": "^4.5.1", "csv-simple-parser": "^1.0.3", "cupertino-pane": "^1.5.4", "ethereum-blockies-base64": "^1.0.2", diff --git a/src/lib/components/icons/ChartBar.svelte b/src/lib/components/icons/ChartBar.svelte new file mode 100644 index 000000000..75049ece7 --- /dev/null +++ b/src/lib/components/icons/ChartBar.svelte @@ -0,0 +1,15 @@ + + + + + + + diff --git a/src/lib/utils/wave/stats.ts b/src/lib/utils/wave/stats.ts new file mode 100644 index 000000000..344b2b0be --- /dev/null +++ b/src/lib/utils/wave/stats.ts @@ -0,0 +1,17 @@ +import { authenticatedCall } from './call'; +import { contributorStatsResponseSchema, contributorAiSummaryResponseSchema } from './types/stats'; +import parseRes from './utils/parse-res'; + +export async function getContributorStats(f = fetch) { + return parseRes( + contributorStatsResponseSchema, + await authenticatedCall(f, '/api/stats/contributor'), + ); +} + +export async function getContributorAiSummary(f = fetch) { + return parseRes( + contributorAiSummaryResponseSchema, + await authenticatedCall(f, '/api/stats/contributor/ai-summary'), + ); +} diff --git a/src/lib/utils/wave/types/stats.ts b/src/lib/utils/wave/types/stats.ts new file mode 100644 index 000000000..b1f1e54a2 --- /dev/null +++ b/src/lib/utils/wave/types/stats.ts @@ -0,0 +1,86 @@ +import z from 'zod'; + +// ====== Points Stats ====== + +export const contributorWavePointsSchema = z.object({ + waveId: z.string(), + waveNumber: z.number().int(), + waveProgramId: z.string(), + waveProgramName: z.string(), + waveProgramSlug: z.string(), + points: z.number().int(), +}); + +export const contributorPointsStatsSchema = z.object({ + totalAllTime: z.number().int(), + byWave: z.array(contributorWavePointsSchema), +}); + +// ====== Issue Stats ====== + +export const contributorIssueStatsSchema = z.object({ + totalResolved: z.number().int(), + byComplexity: z.object({ + small: z.number().int(), + medium: z.number().int(), + large: z.number().int(), + unset: z.number().int(), + }), +}); + +// ====== Leaderboard Stats ====== + +export const contributorLeaderboardWaveEntrySchema = z.object({ + waveId: z.string(), + waveNumber: z.number().int(), + rank: z.number().int(), + totalParticipants: z.number().int(), + points: z.number().int(), +}); + +export const contributorLeaderboardProgramSchema = z.object({ + waveProgramId: z.string(), + waveProgramName: z.string(), + waveProgramSlug: z.string(), + waves: z.array(contributorLeaderboardWaveEntrySchema), +}); + +export const contributorLeaderboardStatsSchema = z.object({ + byProgram: z.array(contributorLeaderboardProgramSchema), +}); + +// ====== Review Stats ====== + +export const contributorReviewStatsSchema = z.object({ + totalReceived: z.number().int(), + experienceDistribution: z.object({ + exceededExpectations: z.number().int(), + alright: z.number().int(), + belowExpectations: z.number().int(), + }), + averageRatings: z.object({ + communicationQuality: z.number().nullable(), + codeQuality: z.number().nullable(), + timeliness: z.number().nullable(), + problemSolving: z.number().nullable(), + }), +}); + +// ====== Combined Response ====== + +export const contributorStatsResponseSchema = z.object({ + points: contributorPointsStatsSchema, + issues: contributorIssueStatsSchema, + leaderboard: contributorLeaderboardStatsSchema, + reviews: contributorReviewStatsSchema.nullable(), +}); +export type ContributorStatsResponse = z.infer; + +// ====== AI Summary Response ====== + +export const contributorAiSummaryResponseSchema = z.object({ + summary: z.string().nullable(), + generatedAt: z.string().nullable(), + reviewCount: z.number().int().nullable(), +}); +export type ContributorAiSummaryResponse = z.infer; diff --git a/src/routes/(pages)/wave/(base-layout)/+layout.svelte b/src/routes/(pages)/wave/(base-layout)/+layout.svelte index a75df9175..3032c7dd0 100644 --- a/src/routes/(pages)/wave/(base-layout)/+layout.svelte +++ b/src/routes/(pages)/wave/(base-layout)/+layout.svelte @@ -16,6 +16,7 @@ import Wave from '$lib/components/icons/Wave.svelte'; import Wallet from '$lib/components/icons/Wallet.svelte'; import Shield from '$lib/components/icons/Shield.svelte'; + import ChartBar from '$lib/components/icons/ChartBar.svelte'; let { data, @@ -111,6 +112,12 @@ href: '/wave/rewards', icon: Wallet, }, + { + type: 'target' as const, + name: 'My Stats', + href: '/wave/stats/me', + icon: ChartBar, + }, { type: 'target' as const, name: 'Points history', diff --git a/src/routes/(pages)/wave/(base-layout)/stats/me/+page.svelte b/src/routes/(pages)/wave/(base-layout)/stats/me/+page.svelte new file mode 100644 index 000000000..771b6cfe0 --- /dev/null +++ b/src/routes/(pages)/wave/(base-layout)/stats/me/+page.svelte @@ -0,0 +1,538 @@ + + + + +
+ + +
+ + {#if stats.points.byWave.length === 0 && stats.points.totalAllTime === 0} +

+ Earn points by completing issues within active Waves. +

+ {:else} +
+ {stats.points.totalAllTime.toLocaleString()} + Total points earned +
+ {#if stats.points.byWave.length > 0} +
+ +
+ {/if} + {/if} +
+
+ + + +
+ + {#if stats.issues.totalResolved === 0} +

Start contributing to see your issue stats here.

+ {:else} +
+ {stats.issues.totalResolved} + Issues resolved +
+
+ +
+ {/if} +
+
+ + + +
+ + {#if stats.leaderboard.byProgram.length === 0} +

Participate in a Wave to see your rankings.

+ {:else} + {#each stats.leaderboard.byProgram as program (program.waveProgramId)} +
+

{program.waveProgramName}

+ {#if program.waves.length === 1} + {@const wave = program.waves[0]} +
+ #{wave.rank} + + of {wave.totalParticipants} participants · Wave {wave.waveNumber} · {wave.points} + pts + +
+ {:else} +
+ +
+ {/if} +
+ {/each} + {/if} +
+
+ + + +
+ + {#if stats.reviews === null} +

Not enough reviews to display stats yet.

+ {:else} +
+ {stats.reviews.totalReceived} + Reviews received +
+
+
+

Experience distribution

+ +
+
+

Average ratings

+ +
+
+ {/if} +
+
+ + +
+ +
+ + {#if aiSummary.summary === null} +

+ Your AI feedback summary hasn't been generated yet. +

+ {:else} +

{aiSummary.summary}

+ {#if aiSummary.generatedAt} + + {/if} + {/if} +
+
+
+
+ + diff --git a/src/routes/(pages)/wave/(base-layout)/stats/me/+page.ts b/src/routes/(pages)/wave/(base-layout)/stats/me/+page.ts new file mode 100644 index 000000000..de84b10d6 --- /dev/null +++ b/src/routes/(pages)/wave/(base-layout)/stats/me/+page.ts @@ -0,0 +1,23 @@ +import { getContributorStats, getContributorAiSummary } from '$lib/utils/wave/stats.js'; +import { redirect } from '@sveltejs/kit'; + +export const load = async ({ parent, url, fetch, depends }) => { + const { user } = await parent(); + + if (!user) { + throw redirect(302, `/wave/login?backTo=${encodeURIComponent(url.pathname + url.search)}`); + } + + depends('wave:stats'); + + const [stats, aiSummary] = await Promise.all([ + getContributorStats(fetch), + getContributorAiSummary(fetch), + ]); + + return { + user, + stats, + aiSummary, + }; +};