From 08ae769906ad2269ad6aa6fdc35467170b5e9d4d Mon Sep 17 00:00:00 2001 From: Teofil Jolte Date: Fri, 7 Feb 2025 15:35:20 +0200 Subject: [PATCH] Leaderboard CSV files --- .../won-slots/side-panel.cy.ts | 1 - frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/app/app.component.ts | 1 - frontend/src/app/app.module.ts | 28 +-- frontend/src/app/app.service.ts | 16 +- .../app/core/services/firestore.service.ts | 22 +-- .../src/app/core/services/rust.service.ts | 1 - .../src/app/core/services/web-node.service.ts | 1 - .../leaderboard-filters.component.scss | 3 +- .../leaderboard-page.component.html | 23 ++- .../leaderboard-page.component.scss | 36 ++++ .../leaderboard-page.component.ts | 16 +- .../leaderboard-title.component.scss | 2 +- .../leaderboard/leaderboard.service.ts | 173 +++++++++++++++++- frontend/src/index.html | 4 +- 16 files changed, 269 insertions(+), 64 deletions(-) diff --git a/frontend/cypress/e2e/block-production/won-slots/side-panel.cy.ts b/frontend/cypress/e2e/block-production/won-slots/side-panel.cy.ts index ae20b18205..9ab26f6571 100644 --- a/frontend/cypress/e2e/block-production/won-slots/side-panel.cy.ts +++ b/frontend/cypress/e2e/block-production/won-slots/side-panel.cy.ts @@ -101,7 +101,6 @@ describe('BLOCK PRODUCTION WON SLOTS SIDE PANEL', () => { .then((state: BlockProductionWonSlotsState) => { expect(state.activeSlot.globalSlot).to.equal(expectedActiveSlot.globalSlot); expect(state.activeSlot.height).to.equal(expectedActiveSlot.height); - console.log(expectedActiveSlot.times); }) .get('mina-block-production-won-slots-side-panel .percentage') .should('have.text', ([ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4844afab1b..21897e6c26 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "1.0.95", + "version": "1.0.129", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "frontend", - "version": "1.0.95", + "version": "1.0.129", "dependencies": { "@angular/animations": "^17.3.12", "@angular/cdk": "^17.3.10", diff --git a/frontend/package.json b/frontend/package.json index 0b095271d9..2573d7f58a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "1.0.124", + "version": "1.0.129", "scripts": { "install:deps": "npm install", "start": "npm install && ng serve --configuration local --open", diff --git a/frontend/src/app/app.component.ts b/frontend/src/app/app.component.ts index 9a3447549a..daa6282a71 100644 --- a/frontend/src/app/app.component.ts +++ b/frontend/src/app/app.component.ts @@ -78,7 +78,6 @@ export class AppComponent extends StoreDispatcher implements OnInit { } private initAppFunctionalities(): void { - console.log('initAppFunctionalities'); if (this.webNodeService.hasWebNodeConfig() && !this.webNodeService.isWebNodeLoaded()) { if (!getWindow()?.location.href.includes(`/${Routes.LOADING_WEB_NODE}`)) { this.router.navigate([Routes.LOADING_WEB_NODE], { queryParamsHandling: 'preserve' }); diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index dba557a132..782b5f6381 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -128,6 +128,21 @@ export class AppGlobalErrorhandler implements ErrorHandler { } } +const firebaseProviders = [ + provideClientHydration(), + provideHttpClient(withFetch()), + provideFirebaseApp(() => initializeApp(CONFIG.globalConfig.firebase)), + provideAnalytics(() => getAnalytics()), + ScreenTrackingService, + // provideAppCheck(() => { + // // TODO get a reCAPTCHA Enterprise here https://console.cloud.google.com/security/recaptcha?project=_ + // const provider = new ReCaptchaEnterpriseProvider(/* reCAPTCHA Enterprise site key */); + // return initializeAppCheck(undefined, { provider, isTokenAutoRefreshEnabled: true }); + // }), + providePerformance(() => getPerformance()), + provideFirestore(() => getFirestore()), +]; + @NgModule({ declarations: [ AppComponent, @@ -181,18 +196,7 @@ export class AppGlobalErrorhandler implements ErrorHandler { deps: [Sentry.TraceService], multi: true, }, - provideClientHydration(), - provideHttpClient(withFetch()), - provideFirebaseApp(() => initializeApp(CONFIG.globalConfig.firebase)), - provideAnalytics(() => getAnalytics()), - ScreenTrackingService, - // provideAppCheck(() => { - // // TODO get a reCAPTCHA Enterprise here https://console.cloud.google.com/security/recaptcha?project=_ - // const provider = new ReCaptchaEnterpriseProvider(/* reCAPTCHA Enterprise site key */); - // return initializeAppCheck(undefined, { provider, isTokenAutoRefreshEnabled: true }); - // }), - providePerformance(() => getPerformance()), - provideFirestore(() => getFirestore()), + ...[CONFIG.globalConfig.firebase ? firebaseProviders : []], ], bootstrap: [AppComponent], exports: [ diff --git a/frontend/src/app/app.service.ts b/frontend/src/app/app.service.ts index 007658b11e..245dca5c98 100644 --- a/frontend/src/app/app.service.ts +++ b/frontend/src/app/app.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { map, Observable, of, tap } from 'rxjs'; +import { map, Observable, of } from 'rxjs'; import { MinaNode } from '@shared/types/core/environment/mina-env.type'; import { CONFIG } from '@shared/constants/config'; import { RustService } from '@core/services/rust.service'; @@ -8,15 +8,13 @@ import { getNetwork } from '@shared/helpers/mina.helper'; import { getLocalStorage, nanOrElse, ONE_MILLION } from '@openmina/shared'; import { BlockProductionWonSlotsStatus } from '@shared/types/block-production/won-slots/block-production-won-slots-slot.type'; import { AppEnvBuild } from '@shared/types/app/app-env-build.type'; -import { FirestoreService } from '@core/services/firestore.service'; @Injectable({ providedIn: 'root', }) export class AppService { - constructor(private rust: RustService, - private firestoreService: FirestoreService) { } + constructor(private rust: RustService) { } getActiveNode(nodes: MinaNode[]): Observable { const nodeName = new URL(location.href).searchParams.get('node'); @@ -54,16 +52,6 @@ export class AppService { producingBlockGlobalSlot: data.current_block_production_attempt?.won_slot.global_slot, producingBlockStatus: data.current_block_production_attempt?.status, } as AppNodeDetails)), - tap((details: any) => { - // undefined not allowed. Firestore does not accept undefined values - // foreach undefined value, we set it to null - Object.keys(details).forEach((key: string) => { - if (details[key] === undefined) { - details[key] = null; - } - }); - // this.firestoreService.addHeartbeat(details); - }), ); } diff --git a/frontend/src/app/core/services/firestore.service.ts b/frontend/src/app/core/services/firestore.service.ts index 438332b22c..813121faf1 100644 --- a/frontend/src/app/core/services/firestore.service.ts +++ b/frontend/src/app/core/services/firestore.service.ts @@ -1,17 +1,7 @@ -import { Injectable } from '@angular/core'; -import { - Firestore, - CollectionReference, - collection, - addDoc, - doc, - setDoc, - updateDoc, - deleteDoc, - DocumentData, -} from '@angular/fire/firestore'; +import { Injectable, Optional } from '@angular/core'; +import { collection, CollectionReference, deleteDoc, doc, DocumentData, Firestore, updateDoc } from '@angular/fire/firestore'; import { HttpClient } from '@angular/common/http'; -import { catchError, EMPTY, Observable, of } from 'rxjs'; +import { catchError, Observable, of } from 'rxjs'; @Injectable({ providedIn: 'root', @@ -20,9 +10,11 @@ export class FirestoreService { private heartbeatCollection: CollectionReference; private cloudFunctionUrl = 'https://us-central1-webnode-gtm-test.cloudfunctions.net/handleValidationAndStore'; - constructor(private firestore: Firestore, + constructor(@Optional() private firestore: Firestore, private http: HttpClient) { - this.heartbeatCollection = collection(this.firestore, 'heartbeat'); + if (this.firestore) { + this.heartbeatCollection = collection(this.firestore, 'heartbeat'); + } } addHeartbeat(data: any): Observable { diff --git a/frontend/src/app/core/services/rust.service.ts b/frontend/src/app/core/services/rust.service.ts index 0e6d26c04f..4a3fe69be5 100644 --- a/frontend/src/app/core/services/rust.service.ts +++ b/frontend/src/app/core/services/rust.service.ts @@ -15,7 +15,6 @@ export class RustService { private webNodeService: WebNodeService) {} changeRustNode(node: MinaNode): void { - console.log('Changing Rust node to:', node); this.node = node; } diff --git a/frontend/src/app/core/services/web-node.service.ts b/frontend/src/app/core/services/web-node.service.ts index 0a1c20e12c..a2a7f49b32 100644 --- a/frontend/src/app/core/services/web-node.service.ts +++ b/frontend/src/app/core/services/web-node.service.ts @@ -60,7 +60,6 @@ export class WebNodeService { } loadWasm$(): Observable { - console.log('Loading wasm'); this.webNodeStartTime = Date.now(); if (isBrowser()) { diff --git a/frontend/src/app/features/leaderboard/leaderboard-filters/leaderboard-filters.component.scss b/frontend/src/app/features/leaderboard/leaderboard-filters/leaderboard-filters.component.scss index 2ac9d53612..81fe7b5c56 100644 --- a/frontend/src/app/features/leaderboard/leaderboard-filters/leaderboard-filters.component.scss +++ b/frontend/src/app/features/leaderboard/leaderboard-filters/leaderboard-filters.component.scss @@ -18,12 +18,12 @@ padding: 0 24px; border-radius: 44px; font-size: 16px; - transition: all 0.15s ease; background-color: $mina-base-divider; color: $black; cursor: pointer; position: relative; overflow: hidden; + transition: all 0.15s ease; &:hover { box-shadow: 0 2px 4px 0 $black3; @@ -106,7 +106,6 @@ .search input, .search input::placeholder { font-size: 3.3vw; - } } } diff --git a/frontend/src/app/features/leaderboard/leaderboard-page/leaderboard-page.component.html b/frontend/src/app/features/leaderboard/leaderboard-page/leaderboard-page.component.html index cd5acc0ee7..81b7e477a0 100644 --- a/frontend/src/app/features/leaderboard/leaderboard-page/leaderboard-page.component.html +++ b/frontend/src/app/features/leaderboard/leaderboard-page/leaderboard-page.component.html @@ -7,7 +7,8 @@
- + +
@@ -34,3 +35,23 @@ + + +
+

Download Public Keys that qualify for the following Prizes:

+
+ + + +
+
+
diff --git a/frontend/src/app/features/leaderboard/leaderboard-page/leaderboard-page.component.scss b/frontend/src/app/features/leaderboard/leaderboard-page/leaderboard-page.component.scss index 5e7f19ef29..274b8d8a98 100644 --- a/frontend/src/app/features/leaderboard/leaderboard-page/leaderboard-page.component.scss +++ b/frontend/src/app/features/leaderboard/leaderboard-page/leaderboard-page.component.scss @@ -86,3 +86,39 @@ main { font-weight: 400; line-height: 24px; } + +p { + font-size: 16px; + line-height: 24px; + color: $mina-base-primary; +} + +.download-btns { + color: $mina-base-primary; + margin-bottom: 80px; + + button { + border-radius: 999px; + font-size: 20px; + gap: 8px; + padding: 0 16px; + font-weight: 300; + transition: all 0.15s ease; + + &:hover { + box-shadow: 0 2px 4px 0 $black3; + } + + &:nth-child(1) { + background-color: $mina-brand-aqua; + } + + &:nth-child(2) { + background-color: $mina-brand-lilac; + } + + &:nth-child(3) { + background-color: $mina-brand-peony; + } + } +} diff --git a/frontend/src/app/features/leaderboard/leaderboard-page/leaderboard-page.component.ts b/frontend/src/app/features/leaderboard/leaderboard-page/leaderboard-page.component.ts index db10f03c3f..f865a4d74e 100644 --- a/frontend/src/app/features/leaderboard/leaderboard-page/leaderboard-page.component.ts +++ b/frontend/src/app/features/leaderboard/leaderboard-page/leaderboard-page.component.ts @@ -6,6 +6,7 @@ import { untilDestroyed } from '@ngneat/until-destroy'; import { trigger, state, style, animate, transition } from '@angular/animations'; import { ManualDetection } from '@openmina/shared'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { LeaderboardService } from '@leaderboard/leaderboard.service'; @Component({ selector: 'mina-leaderboard-page', @@ -44,7 +45,8 @@ export class LeaderboardPageComponent extends StoreDispatcher implements OnInit, private readonly SCROLL_THRESHOLD = 100; @ViewChild('scrollContainer') private scrollContainer!: ElementRef; - constructor(private destroyRef: DestroyRef) { + constructor(private destroyRef: DestroyRef, + private leaderboardService: LeaderboardService) { super(); } @@ -76,4 +78,16 @@ export class LeaderboardPageComponent extends StoreDispatcher implements OnInit, } }); } + + downloadUptimeLottery(): void { + this.leaderboardService.downloadUptimeLottery(); + } + + downloadHighestUptime(): void { + this.leaderboardService.downloadHighestUptime(); + } + + downloadMostProducedBlocks(): void { + this.leaderboardService.downloadMostProducedBlocks(); + } } diff --git a/frontend/src/app/features/leaderboard/leaderboard-title/leaderboard-title.component.scss b/frontend/src/app/features/leaderboard/leaderboard-title/leaderboard-title.component.scss index bcca6c68f7..c65ca7f048 100644 --- a/frontend/src/app/features/leaderboard/leaderboard-title/leaderboard-title.component.scss +++ b/frontend/src/app/features/leaderboard/leaderboard-title/leaderboard-title.component.scss @@ -11,7 +11,7 @@ h1 { font-weight: 300; font-size: 80px; color: $black6; - margin-bottom: 80px; + margin-bottom: 56px; margin-top: 0; @media (max-width: 1023px) { diff --git a/frontend/src/app/features/leaderboard/leaderboard.service.ts b/frontend/src/app/features/leaderboard/leaderboard.service.ts index e9baaadf2f..793d6732d1 100644 --- a/frontend/src/app/features/leaderboard/leaderboard.service.ts +++ b/frontend/src/app/features/leaderboard/leaderboard.service.ts @@ -1,7 +1,7 @@ -import { Injectable } from '@angular/core'; +import { Injectable, Optional } from '@angular/core'; import { combineLatest, map, Observable } from 'rxjs'; import { HeartbeatSummary } from '@shared/types/leaderboard/heartbeat-summary.type'; -import { collection, collectionData, CollectionReference, Firestore } from '@angular/fire/firestore'; +import { collection, collectionData, CollectionReference, Firestore, getDocs } from '@angular/fire/firestore'; import { WebNodeService } from '@core/services/web-node.service'; import { getElapsedTimeInMinsAndHours } from '@shared/helpers/date.helper'; @@ -13,10 +13,14 @@ export class LeaderboardService { private scoresCollection: CollectionReference; private maxScoreCollection: CollectionReference; - constructor(private firestore: Firestore, + private maxScoreRightNow: number; + + constructor(@Optional() private firestore: Firestore, private webnodeService: WebNodeService) { - this.scoresCollection = collection(this.firestore, 'scores'); - this.maxScoreCollection = collection(this.firestore, 'maxScore'); + if (this.firestore) { + this.scoresCollection = collection(this.firestore, 'scores'); + this.maxScoreCollection = collection(this.firestore, 'maxScore'); + } } getHeartbeatsSummaries(): Observable { @@ -25,24 +29,28 @@ export class LeaderboardService { collectionData(this.maxScoreCollection, { idField: 'id' }), ]).pipe( map(([scores, maxScore]) => { - const maxScoreRightNow = maxScore.find(c => c.id === 'current')['value']; + this.maxScoreRightNow = maxScore.find(c => c.id === 'current')['value']; const items = scores.map(score => { return ({ publicKey: score['publicKey'], blocksProduced: score['blocksProduced'], isActive: score['lastUpdated'] > Date.now() - 120000, - uptimePercentage: this.getUptimePercentage(score['score'], maxScoreRightNow), + uptimePercentage: this.getUptimePercentage(score['score'], this.maxScoreRightNow), uptimePrize: false, blocksPrize: false, score: score['score'], - maxScore: maxScoreRightNow, + maxScore: this.maxScoreRightNow, } as HeartbeatSummary); }); const sortedItemsByUptime = [...items].sort((a, b) => b.uptimePercentage - a.uptimePercentage); const fifthPlacePercentageByUptime = sortedItemsByUptime[4]?.uptimePercentage ?? 0; - const highestProducedBlocks = Math.max(...items.map(item => item.blocksProduced)); + const highestProducedBlocks = Math.max( + ...items + .filter(item => item.score > 0.3333 * this.maxScoreRightNow) + .map(item => item.blocksProduced), + ); return items.map(item => ({ ...item, uptimePrize: item.uptimePercentage >= fifthPlacePercentageByUptime, @@ -62,6 +70,13 @@ export class LeaderboardService { map(([scores, maxScore]) => { const activeEntry = scores.find(score => score['publicKey'] === publicKey); + if (!activeEntry) { + return { + uptimePercentage: 0, + uptimeTime: '', + }; + } + return { uptimePercentage: this.getUptimePercentage(activeEntry['score'], maxScore[0]['value']), uptimeTime: getElapsedTimeInMinsAndHours(activeEntry['score'] * 5), @@ -70,6 +85,12 @@ export class LeaderboardService { ); } + private camelCaseToTitle(camelCase: string): string { + return camelCase + .replace(/([A-Z])/g, ' $1') + .replace(/^./, match => match.toUpperCase()); + } + private getUptimePercentage(score: number, maxScore: number): number { let uptimePercentage = Number(((score / maxScore) * 100).toFixed(2)); if (maxScore === 0) { @@ -77,4 +98,138 @@ export class LeaderboardService { } return uptimePercentage; } + + async downloadUptimeLottery(): Promise { + const querySnapshot = await getDocs(this.scoresCollection); + const scoresData: any[] = []; + + querySnapshot.forEach((doc) => { + scoresData.push({ id: doc.id, ...doc.data() }); + }); + + const csvRows = []; + + let filteredData = scoresData + .map(row => ({ + publicKey: row.publicKey, + score: row.score, + })) + .filter(row => row.score > 0.3333 * this.maxScoreRightNow); + filteredData = [...filteredData].sort((a, b) => b.score - a.score); + + const headers = ['publicKey', 'score'].map(header => this.camelCaseToTitle(header)); + csvRows.push(headers.join(',')); + + filteredData.forEach((row: any) => { + const values = headers.map(header => { + const key = header.charAt(0).toLowerCase() + header.slice(1); // Convert to corresponding key + const escape = ('' + row[key.replace(' ', '')]).replace(/"/g, '\\"'); + return `"${escape}"`; + }); + csvRows.push(values.join(',')); + }); + + const csvString = csvRows.join('\n'); + const blob = new Blob([csvString], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = url; + link.download = `export_${new Date().toISOString()}.csv`; + link.click(); + + URL.revokeObjectURL(url); + } + + async downloadHighestUptime(): Promise { + const querySnapshot = await getDocs(this.scoresCollection); + const scoresData: any[] = []; + + querySnapshot.forEach((doc) => { + scoresData.push({ id: doc.id, ...doc.data() }); + }); + + const csvRows = []; + + let filteredData = scoresData + .map(row => ({ + publicKey: row.publicKey, + score: row.score, + })) + .filter(row => row.score > 0.3333 * this.maxScoreRightNow); + filteredData = [...filteredData].sort((a, b) => b.score - a.score); + + const sortedItemsByUptime = [...filteredData].sort((a, b) => b.score - a.score); + const fifthPlaceByUptime = sortedItemsByUptime[4]?.score ?? 0; + filteredData = filteredData.filter(row => row.score >= fifthPlaceByUptime); + + // Convert camelCase headers to Title Case with spaces + const headers = ['publicKey', 'score'].map(header => this.camelCaseToTitle(header)); + csvRows.push(headers.join(',')); + + filteredData.forEach((row: any) => { + const values = headers.map(header => { + const key = header.charAt(0).toLowerCase() + header.slice(1); // Convert to corresponding key + const escape = ('' + row[key.replace(' ', '')]).replace(/"/g, '\\"'); + return `"${escape}"`; + }); + csvRows.push(values.join(',')); + }); + + const csvString = csvRows.join('\n'); + const blob = new Blob([csvString], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = url; + link.download = `export_${new Date().toISOString()}.csv`; + link.click(); + + URL.revokeObjectURL(url); + } + + async downloadMostProducedBlocks(): Promise { + const querySnapshot = await getDocs(this.scoresCollection); + const scoresData: any[] = []; + + querySnapshot.forEach((doc) => { + scoresData.push({ id: doc.id, ...doc.data() }); + }); + + const csvRows = []; + + let filteredData = scoresData + .filter(row => row.score > 0.3333 * this.maxScoreRightNow) + .map(row => ({ + publicKey: row.publicKey, + blocksProduced: row.blocksProduced, + })); + filteredData = [...filteredData].sort((a, b) => b.blocksProduced - a.blocksProduced); + + const highestProducedBlocks = Math.max(...filteredData.map(row => row.blocksProduced)); + filteredData = filteredData.filter(row => row.blocksProduced === highestProducedBlocks); + + const headers = ['publicKey', 'blocksProduced'].map(header => this.camelCaseToTitle(header)); + csvRows.push(headers.join(',')); + + filteredData.forEach((row: any) => { + const values = headers.map(header => { + const key = header.charAt(0).toLowerCase() + header.slice(1); // Convert to corresponding key + const escape = ('' + row[key.replace(' ', '')]).replace(/"/g, '\\"'); + return `"${escape}"`; + }); + csvRows.push(values.join(',')); + }); + + const csvString = csvRows.join('\n'); + const blob = new Blob([csvString], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + + const link = document.createElement('a'); + link.href = url; + link.download = `export_${new Date().toISOString()}.csv`; + link.click(); + + URL.revokeObjectURL(url); + } } diff --git a/frontend/src/index.html b/frontend/src/index.html index b8277f8c19..94afde2475 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -49,11 +49,11 @@