From 381d31e4ca82beea0ed8276cb8bac4494f2de08b Mon Sep 17 00:00:00 2001 From: Michael Schilling Date: Wed, 18 Mar 2026 11:09:14 +0100 Subject: [PATCH 1/3] refactor(admin-portal): migrate charts from chart.js to ECharts Replace chart.js + ng2-charts with echarts + ngx-echarts to align with Framna's standard charting library. Tree-shakeable setup registers only LineChart, BarChart, and required components. --- .../admin-portal/src/app/app.config.ts | 10 ++- .../activity-breakdown-chart.component.ts | 72 +++++++++-------- .../src/app/pages/trends/chart-theme.utils.ts | 73 +++++++++-------- .../app/pages/trends/rank-chart.component.ts | 67 +++++++--------- .../pages/trends/xp-trends-chart.component.ts | 56 ++++++------- package-lock.json | 78 +++++++++---------- package.json | 4 +- 7 files changed, 179 insertions(+), 181 deletions(-) diff --git a/apps/frontend/admin-portal/src/app/app.config.ts b/apps/frontend/admin-portal/src/app/app.config.ts index 3de1d7a1..b8b1a4ec 100644 --- a/apps/frontend/admin-portal/src/app/app.config.ts +++ b/apps/frontend/admin-portal/src/app/app.config.ts @@ -5,11 +5,17 @@ import { initializeApp, provideFirebaseApp } from '@angular/fire/app'; import { connectAuthEmulator, getAuth, provideAuth } from '@angular/fire/auth'; import { getAnalytics, provideAnalytics, ScreenTrackingService } from '@angular/fire/analytics'; -import { provideCharts, withDefaultRegisterables } from 'ng2-charts'; +import { provideEchartsCore } from 'ngx-echarts'; +import * as echarts from 'echarts/core'; +import { LineChart, BarChart } from 'echarts/charts'; +import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'; +import { CanvasRenderer } from 'echarts/renderers'; import { environment } from '../environments/environment'; import { appRoutes } from './app.routes'; +echarts.use([LineChart, BarChart, GridComponent, LegendComponent, TooltipComponent, CanvasRenderer]); + let authEmulatorConnected = false; export const appConfig: ApplicationConfig = { @@ -17,7 +23,7 @@ export const appConfig: ApplicationConfig = { provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(appRoutes, withComponentInputBinding()), provideHttpClient(), - provideCharts(withDefaultRegisterables()), + provideEchartsCore({ echarts }), provideFirebaseApp(() => initializeApp(environment.firebase)), provideAuth(() => { const auth = getAuth(); diff --git a/apps/frontend/admin-portal/src/app/pages/trends/activity-breakdown-chart.component.ts b/apps/frontend/admin-portal/src/app/pages/trends/activity-breakdown-chart.component.ts index 67a816ec..d5ed9040 100644 --- a/apps/frontend/admin-portal/src/app/pages/trends/activity-breakdown-chart.component.ts +++ b/apps/frontend/admin-portal/src/app/pages/trends/activity-breakdown-chart.component.ts @@ -1,18 +1,17 @@ import { Component, computed, input } from '@angular/core'; -import { BaseChartDirective } from 'ng2-charts'; -import { ChartConfiguration } from 'chart.js'; +import { NgxEchartsDirective } from 'ngx-echarts'; import { TrendsEntityData, TrendsProjectData } from '@codeheroes/types'; -import { getChartColor, getDefaultChartOptions, getGridColor, getSubtleTextColor } from './chart-theme.utils'; +import { EChartsOption, getChartColor, getDefaultEChartsOptions } from './chart-theme.utils'; @Component({ selector: 'admin-activity-breakdown-chart', standalone: true, - imports: [BaseChartDirective], + imports: [NgxEchartsDirective], template: `

Activity Breakdown

- +
`, @@ -31,6 +30,7 @@ import { getChartColor, getDefaultChartOptions, getGridColor, getSubtleTextColor margin-bottom: 16px; } .chart-wrapper { height: 320px; } + .chart-wrapper > div { height: 100%; } `, ], }) @@ -38,27 +38,10 @@ export class ActivityBreakdownChartComponent { readonly entities = input.required<(TrendsEntityData | TrendsProjectData)[]>(); readonly weekIds = input.required(); - readonly chartOptions = { - ...getDefaultChartOptions(), - interaction: { mode: 'index' as const, intersect: false }, - scales: { - x: { - stacked: true, - ticks: { color: getSubtleTextColor(), font: { size: 11 } }, - grid: { color: getGridColor() }, - }, - y: { - stacked: true, - beginAtZero: true, - ticks: { color: getSubtleTextColor(), font: { size: 11 } }, - grid: { color: getGridColor() }, - }, - }, - }; - - readonly chartData = computed['data']>(() => { + readonly chartOptions = computed(() => { const labels = [...this.weekIds()].reverse(); const entities = this.entities(); + const defaults = getDefaultEChartsOptions(); // Aggregate counters per week across all entities const weekAggregates = new Map>(); @@ -90,26 +73,49 @@ export class ActivityBreakdownChartComponent { const topActions = sortedActions.slice(0, maxTypes).map(([action]) => action); const hasOther = sortedActions.length > maxTypes; - const datasets = topActions.map((action, i) => ({ - label: action, + const series = topActions.map((action, i) => ({ + name: action, + type: 'bar' as const, + stack: 'total', data: labels.map((weekId) => weekAggregates.get(weekId)?.[action] || 0), - backgroundColor: getChartColor(i), - borderRadius: 2, + itemStyle: { + color: getChartColor(i), + borderRadius: [2, 2, 0, 0] as [number, number, number, number], + }, })); if (hasOther) { const otherActions = sortedActions.slice(maxTypes).map(([action]) => action); - datasets.push({ - label: 'Other', + series.push({ + name: 'Other', + type: 'bar' as const, + stack: 'total', data: labels.map((weekId) => { const agg = weekAggregates.get(weekId) || {}; return otherActions.reduce((sum, action) => sum + (agg[action] || 0), 0); }), - backgroundColor: '#9ca3af', - borderRadius: 2, + itemStyle: { + color: '#9ca3af', + borderRadius: [2, 2, 0, 0] as [number, number, number, number], + }, }); } - return { labels, datasets }; + return { + ...defaults, + tooltip: { + ...(defaults.tooltip as object), + axisPointer: { type: 'shadow' }, + }, + xAxis: { + ...(defaults.xAxis as object), + data: labels, + }, + yAxis: { + ...(defaults.yAxis as object), + min: 0, + }, + series, + }; }); } diff --git a/apps/frontend/admin-portal/src/app/pages/trends/chart-theme.utils.ts b/apps/frontend/admin-portal/src/app/pages/trends/chart-theme.utils.ts index ed8d1d0d..292f74a8 100644 --- a/apps/frontend/admin-portal/src/app/pages/trends/chart-theme.utils.ts +++ b/apps/frontend/admin-portal/src/app/pages/trends/chart-theme.utils.ts @@ -1,4 +1,4 @@ -import { ChartOptions } from 'chart.js'; +import type { EChartsOption } from 'echarts'; const DATAVIZ_VARS = [ '--theme-color-dataviz-categorical-1', // amber/secondary @@ -43,41 +43,46 @@ export function getSurfaceColor(): string { return getCssVar('--theme-color-bg-surface-default') || '#ffffff'; } -export function getDefaultChartOptions(): ChartOptions { +export function getDefaultEChartsOptions(): EChartsOption { return { - responsive: true, - maintainAspectRatio: false, - plugins: { - legend: { - position: 'bottom', - labels: { - color: getTextColor(), - padding: 16, - usePointStyle: true, - pointStyleWidth: 8, - font: { size: 12 }, - }, - }, - tooltip: { - backgroundColor: getSurfaceColor(), - titleColor: getTextColor(), - bodyColor: getSubtleTextColor(), - borderColor: getGridColor(), - borderWidth: 1, - padding: 10, - boxPadding: 4, - usePointStyle: true, - }, + legend: { + bottom: 0, + textStyle: { color: getTextColor(), fontSize: 12 }, + itemWidth: 8, + itemHeight: 8, + icon: 'circle', + padding: [16, 0, 0, 0], }, - scales: { - x: { - ticks: { color: getSubtleTextColor(), font: { size: 11 } }, - grid: { color: getGridColor() }, - }, - y: { - ticks: { color: getSubtleTextColor(), font: { size: 11 } }, - grid: { color: getGridColor() }, - }, + tooltip: { + trigger: 'axis', + backgroundColor: getSurfaceColor(), + borderColor: getGridColor(), + borderWidth: 1, + padding: 10, + textStyle: { color: getTextColor(), fontSize: 12 }, + }, + grid: { + containLabel: true, + left: 12, + right: 12, + top: 12, + bottom: 48, + }, + xAxis: { + type: 'category', + axisLine: { lineStyle: { color: getGridColor() } }, + axisTick: { lineStyle: { color: getGridColor() } }, + axisLabel: { color: getSubtleTextColor(), fontSize: 11 }, + splitLine: { show: false }, + }, + yAxis: { + type: 'value', + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { color: getSubtleTextColor(), fontSize: 11 }, + splitLine: { lineStyle: { color: getGridColor() } }, }, }; } + +export type { EChartsOption }; diff --git a/apps/frontend/admin-portal/src/app/pages/trends/rank-chart.component.ts b/apps/frontend/admin-portal/src/app/pages/trends/rank-chart.component.ts index b6f6a3ac..ec1536a6 100644 --- a/apps/frontend/admin-portal/src/app/pages/trends/rank-chart.component.ts +++ b/apps/frontend/admin-portal/src/app/pages/trends/rank-chart.component.ts @@ -1,18 +1,17 @@ import { Component, computed, input } from '@angular/core'; -import { BaseChartDirective } from 'ng2-charts'; -import { ChartConfiguration } from 'chart.js'; +import { NgxEchartsDirective } from 'ngx-echarts'; import { TrendsEntityData, TrendsProjectData } from '@codeheroes/types'; -import { getChartColor, getDefaultChartOptions, getGridColor, getSubtleTextColor } from './chart-theme.utils'; +import { EChartsOption, getChartColor, getDefaultEChartsOptions, getSubtleTextColor } from './chart-theme.utils'; @Component({ selector: 'admin-rank-chart', standalone: true, - imports: [BaseChartDirective], + imports: [NgxEchartsDirective], template: `

Rank Movement

- +
`, @@ -31,6 +30,7 @@ import { getChartColor, getDefaultChartOptions, getGridColor, getSubtleTextColor margin-bottom: 16px; } .chart-wrapper { height: 320px; } + .chart-wrapper > div { height: 100%; } `, ], }) @@ -38,31 +38,10 @@ export class RankChartComponent { readonly entities = input.required<(TrendsEntityData | TrendsProjectData)[]>(); readonly weekIds = input.required(); - readonly chartOptions = { - ...getDefaultChartOptions(), - interaction: { mode: 'index' as const, intersect: false }, - scales: { - x: { - ticks: { color: getSubtleTextColor(), font: { size: 11 } }, - grid: { color: getGridColor() }, - }, - y: { - reverse: true, - min: 1, - ticks: { - stepSize: 1, - color: getSubtleTextColor(), - font: { size: 11 }, - }, - grid: { color: getGridColor() }, - title: { display: true, text: 'Rank', color: getSubtleTextColor() }, - }, - }, - }; - - readonly chartData = computed['data']>(() => { + readonly chartOptions = computed(() => { const labels = [...this.weekIds()].reverse(); const top10 = this.entities().slice(0, 10); + const defaults = getDefaultEChartsOptions(); // Calculate rank per week const rankPerWeek = new Map>(); @@ -82,20 +61,32 @@ export class RankChartComponent { } return { - labels, - datasets: top10.map((entity, i) => ({ - label: 'displayName' in entity ? entity.displayName : entity.name, + ...defaults, + xAxis: { + ...(defaults.xAxis as object), + data: labels, + }, + yAxis: { + ...(defaults.yAxis as object), + inverse: true, + min: 1, + minInterval: 1, + name: 'Rank', + nameTextStyle: { color: getSubtleTextColor() }, + }, + series: top10.map((entity, i) => ({ + name: 'displayName' in entity ? entity.displayName : entity.name, + type: 'line' as const, + smooth: 0.3, + connectNulls: true, data: labels.map((weekId) => { const rank = rankPerWeek.get(weekId)?.get(entity.id); return rank && !isNaN(rank) ? rank : null; }), - borderColor: getChartColor(i), - backgroundColor: getChartColor(i) + '20', - borderWidth: 2, - pointRadius: 4, - pointHoverRadius: 6, - tension: 0.3, - spanGaps: true, + itemStyle: { color: getChartColor(i) }, + lineStyle: { color: getChartColor(i), width: 2 }, + symbolSize: 8, + symbol: 'circle', })), }; }); diff --git a/apps/frontend/admin-portal/src/app/pages/trends/xp-trends-chart.component.ts b/apps/frontend/admin-portal/src/app/pages/trends/xp-trends-chart.component.ts index 72d980f5..04324b98 100644 --- a/apps/frontend/admin-portal/src/app/pages/trends/xp-trends-chart.component.ts +++ b/apps/frontend/admin-portal/src/app/pages/trends/xp-trends-chart.component.ts @@ -1,18 +1,17 @@ import { Component, computed, input } from '@angular/core'; -import { BaseChartDirective } from 'ng2-charts'; -import { ChartConfiguration } from 'chart.js'; +import { NgxEchartsDirective } from 'ngx-echarts'; import { TrendsEntityData, TrendsProjectData } from '@codeheroes/types'; -import { getChartColor, getDefaultChartOptions, getGridColor, getSubtleTextColor } from './chart-theme.utils'; +import { EChartsOption, getChartColor, getDefaultEChartsOptions } from './chart-theme.utils'; @Component({ selector: 'admin-xp-trends-chart', standalone: true, - imports: [BaseChartDirective], + imports: [NgxEchartsDirective], template: `

XP Trends

- +
`, @@ -31,6 +30,7 @@ import { getChartColor, getDefaultChartOptions, getGridColor, getSubtleTextColor margin-bottom: 16px; } .chart-wrapper { height: 320px; } + .chart-wrapper > div { height: 100%; } `, ], }) @@ -38,41 +38,33 @@ export class XpTrendsChartComponent { readonly entities = input.required<(TrendsEntityData | TrendsProjectData)[]>(); readonly weekIds = input.required(); - readonly chartOptions = { - ...getDefaultChartOptions(), - interaction: { mode: 'index' as const, intersect: false }, - scales: { - x: { - ticks: { color: getSubtleTextColor(), font: { size: 11 } }, - grid: { color: getGridColor() }, - }, - y: { - beginAtZero: true, - ticks: { color: getSubtleTextColor(), font: { size: 11 } }, - grid: { color: getGridColor() }, - }, - }, - }; - - readonly chartData = computed['data']>(() => { + readonly chartOptions = computed(() => { const labels = [...this.weekIds()].reverse(); const top10 = this.entities().slice(0, 10); + const defaults = getDefaultEChartsOptions(); return { - labels, - datasets: top10.map((entity, i) => ({ - label: 'displayName' in entity ? entity.displayName : entity.name, + ...defaults, + xAxis: { + ...(defaults.xAxis as object), + data: labels, + }, + yAxis: { + ...(defaults.yAxis as object), + min: 0, + }, + series: top10.map((entity, i) => ({ + name: 'displayName' in entity ? entity.displayName : entity.name, + type: 'line' as const, + smooth: 0.3, data: labels.map((weekId) => { const week = entity.weeklyData.find((w) => w.weekId === weekId); return week?.xpGained || 0; }), - borderColor: getChartColor(i), - backgroundColor: getChartColor(i) + '20', - borderWidth: 2, - pointRadius: 3, - pointHoverRadius: 5, - tension: 0.3, - fill: false, + itemStyle: { color: getChartColor(i) }, + lineStyle: { color: getChartColor(i), width: 2 }, + symbolSize: 6, + symbol: 'circle', })), }; }); diff --git a/package-lock.json b/package-lock.json index 37bf02c2..58029396 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,9 +26,9 @@ "@tailwindcss/postcss": "^4.2.1", "angular-svg-icon": "^19.1.1", "axios": "^1.13.6", - "chart.js": "^4.5.1", "class-transformer": "^0.5.1", "date-fns": "^4.1.0", + "echarts": "^6.0.0", "eslint-plugin-import": "^2.32.0", "eslint-plugin-simple-import-sort": "^12.1.1", "express-rate-limit": "^8.3.1", @@ -36,7 +36,7 @@ "firebase-admin": "^13.7.0", "firebase-functions": "^7.2.0", "helmet": "^8.1.0", - "ng2-charts": "^10.0.0", + "ngx-echarts": "^21.0.0", "postcss": "^8.5.1", "quill": "^2.0.3", "reflect-metadata": "^0.2.2", @@ -9117,12 +9117,6 @@ "tslib": "2" } }, - "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/@leichtgewicht/ip-codec": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", @@ -18297,18 +18291,6 @@ "node": ">=10" } }, - "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": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -20358,6 +20340,22 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/echarts": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.0.0.tgz", + "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.0.0" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -20704,16 +20702,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es-toolkit": { - "version": "1.45.1", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", - "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] - }, "node_modules/esbuild": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", @@ -29314,22 +29302,17 @@ "node": ">= 0.4.0" } }, - "node_modules/ng2-charts": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/ng2-charts/-/ng2-charts-10.0.0.tgz", - "integrity": "sha512-mdL75XJrk/0s0YO2ySPQpAHPja85ECDEGNWFlcElJiy/bYliTNGEpeCtctAqZuozTff/E2CwGjyfPFM1ScP2og==", + "node_modules/ngx-echarts": { + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/ngx-echarts/-/ngx-echarts-21.0.0.tgz", + "integrity": "sha512-vivBRmGYMFlnPxK/uxliY+sexGwHqnFZ2pylGbIMF2wikt9RpZpGGSitLmuUXvzYwkVFGo6dQmiUQh1+6Pbfuw==", "license": "MIT", "dependencies": { - "es-toolkit": "^1.39.7", "tslib": "^2.3.0" }, "peerDependencies": { - "@angular/cdk": ">=21.0.0", - "@angular/common": ">=21.0.0", "@angular/core": ">=21.0.0", - "@angular/platform-browser": ">=21.0.0", - "chart.js": "^3.4.0 || ^4.0.0", - "rxjs": "^6.5.3 || ^7.4.0" + "echarts": ">=5.0.0" } }, "node_modules/node-abort-controller": { @@ -38396,6 +38379,21 @@ "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.16.1.tgz", "integrity": "sha512-dpvY17vxYIW3+bNrP0ClUlaiY0CiIRK3tnoLaGoQsQcY9/I/NpzIWQ7tQNhbV7LacQMpCII6wVzuL3tuWOyfuA==", "license": "MIT" + }, + "node_modules/zrender": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz", + "integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" } } } diff --git a/package.json b/package.json index 9a9b52f1..1d07f6cd 100644 --- a/package.json +++ b/package.json @@ -30,9 +30,9 @@ "@tailwindcss/postcss": "^4.2.1", "angular-svg-icon": "^19.1.1", "axios": "^1.13.6", - "chart.js": "^4.5.1", "class-transformer": "^0.5.1", "date-fns": "^4.1.0", + "echarts": "^6.0.0", "eslint-plugin-import": "^2.32.0", "eslint-plugin-simple-import-sort": "^12.1.1", "express-rate-limit": "^8.3.1", @@ -40,7 +40,7 @@ "firebase-admin": "^13.7.0", "firebase-functions": "^7.2.0", "helmet": "^8.1.0", - "ng2-charts": "^10.0.0", + "ngx-echarts": "^21.0.0", "postcss": "^8.5.1", "quill": "^2.0.3", "reflect-metadata": "^0.2.2", From 92b52a16dd4e8ee37d422e5b75ff6d9685dc69b2 Mon Sep 17 00:00:00 2001 From: Michael Schilling Date: Wed, 18 Mar 2026 11:27:25 +0100 Subject: [PATCH 2/3] refactor(admin-portal): redesign Top Movers to match Home stat-card style Prominent stat value (28px) with subtle name below. Limit "New in Top 10" to 3 visible names with "+N more" overflow to keep cards equal height. --- .../app/pages/trends/top-movers.component.ts | 45 +++++++++++++------ 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/apps/frontend/admin-portal/src/app/pages/trends/top-movers.component.ts b/apps/frontend/admin-portal/src/app/pages/trends/top-movers.component.ts index 1a9e8eb0..89497480 100644 --- a/apps/frontend/admin-portal/src/app/pages/trends/top-movers.component.ts +++ b/apps/frontend/admin-portal/src/app/pages/trends/top-movers.component.ts @@ -1,4 +1,4 @@ -import { Component, input } from '@angular/core'; +import { Component, computed, input } from '@angular/core'; import { TrendsEntityData, TrendsProjectData } from '@codeheroes/types'; export interface TopMoversData { @@ -78,8 +78,8 @@ export function computeTopMovers(
Biggest Climber @if (data().biggestClimber) { - {{ data().biggestClimber!.name }} - +{{ data().biggestClimber!.rankChange }} ranks + +{{ data().biggestClimber!.rankChange }} ranks + {{ data().biggestClimber!.name }} } @else { No data } @@ -87,8 +87,8 @@ export function computeTopMovers(
Most Consistent @if (data().mostConsistent) { - {{ data().mostConsistent!.name }} - {{ data().mostConsistent!.activeWeeks }}/{{ data().mostConsistent!.totalWeeks }} weeks active + {{ data().mostConsistent!.activeWeeks }}/{{ data().mostConsistent!.totalWeeks }} weeks + {{ data().mostConsistent!.name }} } @else { No data } @@ -96,9 +96,8 @@ export function computeTopMovers(
New in Top 10 @if (data().newInTopTen.length > 0) { - @for (entry of data().newInTopTen; track entry.id) { - {{ entry.name }} - } + {{ data().newInTopTen.length }} newcomers + {{ topNewcomers() }}@if (overflowCount() > 0) { +{{ overflowCount() }} more} } @else { No newcomers } @@ -119,22 +118,30 @@ export function computeTopMovers( padding: 20px; display: flex; flex-direction: column; - gap: 4px; + gap: 8px; } .mover-label { font-size: 13px; font-weight: 500; color: var(--theme-color-text-neutral-tertiary); } - .mover-value { - font-size: 18px; + .mover-stat { + font-size: 28px; font-weight: 700; color: var(--theme-color-text-default); } - .mover-detail { - font-size: 13px; - color: var(--theme-color-text-brand-default); + .mover-name { + font-size: 14px; font-weight: 500; + color: var(--theme-color-text-neutral-tertiary); + } + .mover-names { + font-size: 14px; + font-weight: 500; + color: var(--theme-color-text-neutral-tertiary); + } + .mover-overflow { + color: var(--theme-color-text-brand-default); } .mover-empty { font-size: 14px; @@ -144,5 +151,15 @@ export function computeTopMovers( ], }) export class TopMoversComponent { + private static readonly MAX_VISIBLE = 3; + readonly data = input.required(); + + readonly topNewcomers = computed(() => + this.data().newInTopTen.slice(0, TopMoversComponent.MAX_VISIBLE).map((e) => e.name).join(', '), + ); + + readonly overflowCount = computed(() => + Math.max(0, this.data().newInTopTen.length - TopMoversComponent.MAX_VISIBLE), + ); } From ec46b1f9a65a712ec19b559ab84e8f4b5f85783f Mon Sep 17 00:00:00 2001 From: Michael Schilling Date: Wed, 18 Mar 2026 15:01:45 +0100 Subject: [PATCH 3/3] feat(admin-portal): add configurable week range selector to trends page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow users to switch between 4, 10, 26, and 52 week views on the trends page. The API already supported the weeks parameter — this adds pill buttons to the filter bar for quick selection. --- .../src/app/pages/trends/trends.component.ts | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/apps/frontend/admin-portal/src/app/pages/trends/trends.component.ts b/apps/frontend/admin-portal/src/app/pages/trends/trends.component.ts index b451cd37..f5a93768 100644 --- a/apps/frontend/admin-portal/src/app/pages/trends/trends.component.ts +++ b/apps/frontend/admin-portal/src/app/pages/trends/trends.component.ts @@ -24,7 +24,7 @@ type TabType = 'heroes' | 'bots' | 'projects'; @@ -34,6 +34,11 @@ type TabType = 'heroes' | 'bots' | 'projects';
+
+ @for (w of weekOptions; track w) { + + } +
@if (isLoading()) { @@ -84,12 +89,17 @@ type TabType = 'heroes' | 'bots' | 'projects'; .filters { display: flex; align-items: center; + justify-content: space-between; margin-bottom: 20px; } .filter-group { display: flex; gap: 4px; } + .filter-pill--sm { + padding: 4px 10px; + font-size: 12px; + } .filter-pill { padding: 6px 14px; border: 1px solid var(--theme-color-border-default-default); @@ -148,7 +158,8 @@ type TabType = 'heroes' | 'bots' | 'projects'; export class TrendsComponent implements OnInit { readonly #trendsService = inject(TrendsService); - readonly weekCount = 10; + readonly weekOptions = [4, 10, 26, 52] as const; + readonly weekCount = signal(10); readonly trendsData = signal(null); readonly selectedTab = signal('heroes'); readonly isLoading = signal(true); @@ -177,11 +188,18 @@ export class TrendsComponent implements OnInit { this.loadTrends(); } + selectWeeks(weeks: number): void { + if (weeks !== this.weekCount()) { + this.weekCount.set(weeks); + this.loadTrends(); + } + } + loadTrends(): void { this.isLoading.set(true); this.error.set(null); - this.#trendsService.getTrends(this.weekCount).subscribe({ + this.#trendsService.getTrends(this.weekCount()).subscribe({ next: (data) => { this.trendsData.set(data); this.isLoading.set(false);