From e12828d4ae6e6d7c78f493fbbbc7791ffebcac3f Mon Sep 17 00:00:00 2001 From: Xharles Date: Tue, 21 Jul 2026 09:25:07 +0100 Subject: [PATCH 1/2] setup widget --- .../developerActivityEndpoints.ts | 68 +++++++ .../api/services/developerActivity/types.ts | 31 +++ packages/frontend/src/api/services/index.ts | 3 + .../src/api/types/developerActivity.ts | 20 ++ packages/frontend/src/api/types/index.ts | 1 + .../DeveloperActivityChart.tsx | 123 ++++++++++++ .../DeveloperActivityModule.tsx | 177 ++++++++++++++++++ packages/frontend/src/config/backend.ts | 2 + packages/frontend/src/config/widgets.ts | 8 + packages/frontend/src/constants.ts | 1 + .../DeveloperActivityContainer.tsx | 52 +++++ packages/frontend/src/types.ts | 4 + 12 files changed, 490 insertions(+) create mode 100644 packages/frontend/src/api/services/developerActivity/developerActivityEndpoints.ts create mode 100644 packages/frontend/src/api/services/developerActivity/types.ts create mode 100644 packages/frontend/src/api/types/developerActivity.ts create mode 100644 packages/frontend/src/components/developerActivity/DeveloperActivityChart.tsx create mode 100644 packages/frontend/src/components/developerActivity/DeveloperActivityModule.tsx create mode 100644 packages/frontend/src/containers/developerActivity/DeveloperActivityContainer.tsx diff --git a/packages/frontend/src/api/services/developerActivity/developerActivityEndpoints.ts b/packages/frontend/src/api/services/developerActivity/developerActivityEndpoints.ts new file mode 100644 index 000000000..1b49a69cf --- /dev/null +++ b/packages/frontend/src/api/services/developerActivity/developerActivityEndpoints.ts @@ -0,0 +1,68 @@ +import queryString from "query-string"; +import { TDeveloperActivitySnapshot } from "src/api/types"; +import { Logger } from "src/api/utils/logging"; +import CONFIG from "../../../config/config"; +import { alphadayApi } from "../alphadayApi"; +import { + TGetDeveloperActivityRequest, + TGetDeveloperActivityRawResponse, + TGetDeveloperActivityResponse, +} from "./types"; + +const { COINS } = CONFIG.API.DEFAULT.ROUTES; + +const developerActivityApi = alphadayApi.injectEndpoints({ + endpoints: (builder) => ({ + getDeveloperActivity: builder.query< + TGetDeveloperActivityResponse, + TGetDeveloperActivityRequest + >({ + query: ({ coin, limit }) => { + const params = queryString.stringify( + { limit }, + { skipNull: true, skipEmptyString: true } + ); + const path = `${String(COINS.BASE)}${String( + COINS.DEVELOPER_ACTIVITY(coin) + )}${params ? `?${params}` : ""}`; + Logger.debug("getDeveloperActivity: querying", path); + return path; + }, + transformResponse: ( + r: TGetDeveloperActivityRawResponse + ): TGetDeveloperActivityResponse => { + try { + const snapshots: TDeveloperActivitySnapshot[] = r + .map((s) => ({ + id: s.id, + coin: s.coin, + commitsLast4Weeks: s.commits_last_4_weeks, + contributorsCount: s.contributors_count, + openIssues: s.open_issues, + stars: s.stars, + forks: s.forks, + date: s.date, + })) + // The endpoint returns snapshots newest-first; sort + // oldest-first so the data is ready to plot on a time + // axis and the latest snapshot is the last element. + .sort( + (a, b) => + new Date(a.date).getTime() - + new Date(b.date).getTime() + ); + return { snapshots }; + } catch (error) { + Logger.error( + "getDeveloperActivity::transformResponse: error while parsing response:", + error + ); + return { snapshots: [] }; + } + }, + }), + }), + overrideExisting: false, +}); + +export const { useGetDeveloperActivityQuery } = developerActivityApi; diff --git a/packages/frontend/src/api/services/developerActivity/types.ts b/packages/frontend/src/api/services/developerActivity/types.ts new file mode 100644 index 000000000..1bb126140 --- /dev/null +++ b/packages/frontend/src/api/services/developerActivity/types.ts @@ -0,0 +1,31 @@ +import { TDeveloperActivityData } from "src/api/types"; + +/** + * Primitive types + */ + +export type TRemoteDeveloperActivitySnapshot = { + id: number; + coin: string; + commits_last_4_weeks: number; + contributors_count: number; + open_issues: number; + stars: number; + forks: number; + date: string; +}; + +/** + * Queries + */ + +export type TGetDeveloperActivityRequest = { + // coin slug (case insensitive) — required path param + coin: string; + limit?: number | undefined; +}; + +export type TGetDeveloperActivityRawResponse = + TRemoteDeveloperActivitySnapshot[]; + +export type TGetDeveloperActivityResponse = TDeveloperActivityData; diff --git a/packages/frontend/src/api/services/index.ts b/packages/frontend/src/api/services/index.ts index 107128f27..bb58cc309 100644 --- a/packages/frontend/src/api/services/index.ts +++ b/packages/frontend/src/api/services/index.ts @@ -44,6 +44,9 @@ export * from "./yields/types"; export * from "./fees/feesEndpoints"; export * from "./fees/types"; +export * from "./developerActivity/developerActivityEndpoints"; +export * from "./developerActivity/types"; + export * from "./projects/projectsEndpoints"; export * from "./projects/types"; diff --git a/packages/frontend/src/api/types/developerActivity.ts b/packages/frontend/src/api/types/developerActivity.ts new file mode 100644 index 000000000..20fdaef92 --- /dev/null +++ b/packages/frontend/src/api/types/developerActivity.ts @@ -0,0 +1,20 @@ +/** + * The `/coins/{coin}/developer-activity/` endpoint returns GitHub + * developer-activity snapshots for a coin, newest first. Each snapshot is a + * point-in-time reading of the coin's main repository. + */ +export type TDeveloperActivitySnapshot = { + id: number; + coin: string; + commitsLast4Weeks: number; + contributorsCount: number; + openIssues: number; + stars: number; + forks: number; + date: string; +}; + +export type TDeveloperActivityData = { + // snapshots ordered oldest -> newest (ready to plot on a time axis) + snapshots: TDeveloperActivitySnapshot[]; +}; diff --git a/packages/frontend/src/api/types/index.ts b/packages/frontend/src/api/types/index.ts index 698e06373..36af3290a 100644 --- a/packages/frontend/src/api/types/index.ts +++ b/packages/frontend/src/api/types/index.ts @@ -3,6 +3,7 @@ export * from "./blog"; export * from "./chat"; export * from "./custom"; export * from "./dao"; +export * from "./developerActivity"; export * from "./discord"; export * from "./event"; export * from "./features"; diff --git a/packages/frontend/src/components/developerActivity/DeveloperActivityChart.tsx b/packages/frontend/src/components/developerActivity/DeveloperActivityChart.tsx new file mode 100644 index 000000000..88430843a --- /dev/null +++ b/packages/frontend/src/components/developerActivity/DeveloperActivityChart.tsx @@ -0,0 +1,123 @@ +import { FC, useMemo } from "react"; +import { ApexAreaChart, themeColors } from "@alphaday/ui-kit"; +import { formatNumber, ENumberStyle } from "src/api/utils/format"; + +interface IProps { + // [unix timestamp (seconds), value] pairs, oldest first + series: Array<[number, number]>; + height: number; +} + +const DeveloperActivityChart: FC = ({ series, height }) => { + const chartSeries = useMemo( + () => [ + { + name: "value", + data: + series.length > 0 + ? series + : ([[0, 0]] as Array<[number, number]>), + }, + ], + [series] + ); + + const isUptrend = + series.length > 1 && series[series.length - 1][1] >= series[0][1]; + const lineColor = isUptrend + ? themeColors.success + : themeColors.secondaryOrangeSoda; + + const chartOptions = useMemo( + () => ({ + chart: { + sparkline: { enabled: false }, + toolbar: { show: false }, + zoom: { enabled: false }, + animations: { enabled: false }, + }, + colors: [lineColor], + dataLabels: { enabled: false }, + stroke: { + curve: "smooth", + width: 1.7, + }, + fill: { + type: "gradient", + gradient: { + shade: "light", + type: "vertical", + shadeIntensity: 1, + gradientToColors: [lineColor], + inverseColors: false, + opacityFrom: 0.4, + opacityTo: 0.05, + }, + }, + legend: { show: false }, + xaxis: { + type: "datetime", + axisBorder: { show: false }, + axisTicks: { show: false }, + tooltip: { enabled: false }, + labels: { + style: { + colors: themeColors.primaryVariant100, + fontSize: "10px", + fontFamily: "Arial, sans-serif", + fontWeight: 400, + }, + }, + }, + yaxis: { + show: true, + tickAmount: 3, + decimalsInFloat: false, + labels: { + style: { + colors: [themeColors.primaryVariant100], + fontSize: "10px", + fontFamily: "Arial, sans-serif", + fontWeight: 400, + }, + formatter: (value: number) => + formatNumber({ + value, + style: ENumberStyle.Decimal, + }).value, + }, + }, + grid: { + borderColor: themeColors.borderLine, + xaxis: { lines: { show: false } }, + yaxis: { lines: { show: true } }, + padding: { left: 4, right: 8, top: 0, bottom: 0 }, + }, + tooltip: { + enabled: true, + theme: "dark", + x: { format: "dd MMM yyyy" }, + y: { + formatter: (value: number) => + formatNumber({ + value, + style: ENumberStyle.Decimal, + }).value, + title: { formatter: () => "" }, + }, + }, + }), + [lineColor] + ); + + return ( + + ); +}; + +export default DeveloperActivityChart; diff --git a/packages/frontend/src/components/developerActivity/DeveloperActivityModule.tsx b/packages/frontend/src/components/developerActivity/DeveloperActivityModule.tsx new file mode 100644 index 000000000..1771c1bd0 --- /dev/null +++ b/packages/frontend/src/components/developerActivity/DeveloperActivityModule.tsx @@ -0,0 +1,177 @@ +import { FC, memo, useMemo, useState } from "react"; +import { ModuleLoader } from "@alphaday/ui-kit"; +import moment from "moment-with-locales-es6"; +import { TDeveloperActivitySnapshot } from "src/api/types"; +import { formatNumber, ENumberStyle } from "src/api/utils/format"; +import globalMessages from "src/globalMessages"; +import DeveloperActivityChart from "./DeveloperActivityChart"; + +export enum EDeveloperActivityMetric { + Stars = "stars", + Forks = "forks", + Commits = "commitsLast4Weeks", + Contributors = "contributorsCount", + OpenIssues = "openIssues", +} + +type TMetricKey = keyof Pick< + TDeveloperActivitySnapshot, + "stars" | "forks" | "commitsLast4Weeks" | "contributorsCount" | "openIssues" +>; + +const METRICS: Array<{ + key: TMetricKey; + value: EDeveloperActivityMetric; + label: string; +}> = [ + { + key: "stars", + value: EDeveloperActivityMetric.Stars, + label: "Stars", + }, + { + key: "forks", + value: EDeveloperActivityMetric.Forks, + label: "Forks", + }, + { + key: "commitsLast4Weeks", + value: EDeveloperActivityMetric.Commits, + label: "Commits (4w)", + }, + { + key: "contributorsCount", + value: EDeveloperActivityMetric.Contributors, + label: "Contributors", + }, + { + key: "openIssues", + value: EDeveloperActivityMetric.OpenIssues, + label: "Open Issues", + }, +]; + +const formatCount = (value: number): string => + formatNumber({ value, style: ENumberStyle.Decimal }).value; + +const StatTile: FC<{ + label: string; + value: number; + active: boolean; + onClick: () => void; +}> = ({ label, value, active, onClick }) => ( + +); + +interface IDeveloperActivity { + isLoading: boolean; + snapshots: TDeveloperActivitySnapshot[] | undefined; + coin: string | undefined; + widgetHeight: number; +} + +const DeveloperActivityModule: FC = memo( + function DeveloperActivityModule({ + isLoading, + snapshots, + coin, + widgetHeight, + }) { + const [selectedMetric, setSelectedMetric] = + useState(EDeveloperActivityMetric.Stars); + + const latest = snapshots?.[snapshots.length - 1]; + + const selectedMetricConfig = + METRICS.find((m) => m.value === selectedMetric) ?? METRICS[0]; + + const chartSeries = useMemo>(() => { + if (!snapshots) return []; + return snapshots.map((s) => [ + moment(s.date).unix(), + Number(s[selectedMetricConfig.key]), + ]); + }, [snapshots, selectedMetricConfig.key]); + + const TILES_HEIGHT = 60; + const chartHeight = Math.max(widgetHeight - TILES_HEIGHT, 120); + + if (isLoading) { + return ; + } + + if (coin === undefined) { + return ( +
+

+ Pin a coin to this widget to see its GitHub developer + activity. +

+
+ ); + } + + if (latest === undefined || snapshots?.length === 0) { + return ( +
+

+ {globalMessages.queries.noMatchFound( + "developer activity" + )} +

+
+ ); + } + + return ( +
+
+ {METRICS.map((metric) => ( + setSelectedMetric(metric.value)} + /> + ))} +
+
+ + {selectedMetricConfig.label} + + + {moment(latest.date).format("DD MMM YYYY")} + +
+ +
+ ); + } +); + +export default DeveloperActivityModule; diff --git a/packages/frontend/src/config/backend.ts b/packages/frontend/src/config/backend.ts index c31015b4e..94c750334 100644 --- a/packages/frontend/src/config/backend.ts +++ b/packages/frontend/src/config/backend.ts @@ -42,6 +42,8 @@ const API_V0 = { PINNED: "/pinned_coins/", KASANDRA_SUPPORTED: "/kasandra_supported/", PIN: (id: number): string => `/${id}/toggle_pin/`, + DEVELOPER_ACTIVITY: (coin: string): string => + `/${coin}/developer-activity/`, }, DAO: { BASE: "items/dao", diff --git a/packages/frontend/src/config/widgets.ts b/packages/frontend/src/config/widgets.ts index bd8a11519..ecb4ae8c7 100644 --- a/packages/frontend/src/config/widgets.ts +++ b/packages/frontend/src/config/widgets.ts @@ -260,6 +260,14 @@ export const WIDGETS_CONFIG = { MAX_PAGE_NUMBER: 10, ADJUSTABLE: true, }, + [ETemplateNameRegistry.DeveloperActivity]: { + TAG_ITEM_TYPE: "coin", + POLLING_INTERVAL: 180, // 3 min + WIDGET_HEIGHT: 253, + // number of snapshots to request (newest first) + SNAPSHOT_LIMIT: 30, + ADJUSTABLE: true, + }, [ETemplateNameRegistry.Video]: { WIDGET_HEIGHT: 650, MAX_PAGE_NUMBER: 20, diff --git a/packages/frontend/src/constants.ts b/packages/frontend/src/constants.ts index f58e6b0bd..c94f0af41 100644 --- a/packages/frontend/src/constants.ts +++ b/packages/frontend/src/constants.ts @@ -61,6 +61,7 @@ export enum ETemplateNameRegistry { CustomChart = "CUSTOM_CHART", CustomTable = "CUSTOM_TABLE", Dao = "DAO", + DeveloperActivity = "DEVELOPER_ACTIVITY", Discord = "DISCORD", Dune = "DUNE", DuneTable = "DUNE_TABLE", diff --git a/packages/frontend/src/containers/developerActivity/DeveloperActivityContainer.tsx b/packages/frontend/src/containers/developerActivity/DeveloperActivityContainer.tsx new file mode 100644 index 000000000..cd308fdc1 --- /dev/null +++ b/packages/frontend/src/containers/developerActivity/DeveloperActivityContainer.tsx @@ -0,0 +1,52 @@ +import { FC } from "react"; +import { useWidgetHeight } from "src/api/hooks"; +import { useGetDeveloperActivityQuery } from "src/api/services"; +import DeveloperActivityModule from "src/components/developerActivity/DeveloperActivityModule"; +import CONFIG from "src/config"; +import { EWidgetSettingsRegistry } from "src/constants"; +import { IModuleContainer } from "src/types"; + +const { SNAPSHOT_LIMIT, POLLING_INTERVAL } = CONFIG.WIDGETS.DEVELOPER_ACTIVITY; + +const DeveloperActivityContainer: FC = ({ + moduleData, + mobileViewWidgetHeight, +}) => { + const widgetHeight = useWidgetHeight(moduleData); + + // This widget is coin-scoped: the coin is taken from the widget's included + // tags. We use the first pinned tag as the target coin. + const tagsSettings = moduleData.settings.filter( + (s) => + s.widget_setting.setting.slug === + EWidgetSettingsRegistry.IncludedTags + ); + const tags = tagsSettings[0]?.tags; + const coin = tags?.[0]?.slug; + + const pollingInterval = + (moduleData.widget.refresh_interval || POLLING_INTERVAL) * 1000; + + const { currentData: developerActivity, isLoading } = + useGetDeveloperActivityQuery( + { + coin: coin ?? "", + limit: SNAPSHOT_LIMIT, + }, + { + pollingInterval, + skip: coin === undefined, + } + ); + + return ( + + ); +}; + +export default DeveloperActivityContainer; diff --git a/packages/frontend/src/types.ts b/packages/frontend/src/types.ts index 7a2f25c46..14096ca7a 100644 --- a/packages/frontend/src/types.ts +++ b/packages/frontend/src/types.ts @@ -71,6 +71,10 @@ export const TEMPLATES_DICT: Partial = { () => import("./containers/custom-modules/CustomTableContainer") ), dao_template: lazyRetry(() => import("./containers/items/ItemsContainer")), + developer_activity_template: lazyRetry( + () => + import("./containers/developerActivity/DeveloperActivityContainer") + ), discord_template: lazyRetry( () => import("./containers/items/ItemsContainer") ), From 54599a2a7d9c70bddd498184533c3a73d560f443 Mon Sep 17 00:00:00 2001 From: Xharles Date: Wed, 22 Jul 2026 06:29:29 +0100 Subject: [PATCH 2/2] improve card --- .../developerActivity/DeveloperActivityContainer.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/frontend/src/containers/developerActivity/DeveloperActivityContainer.tsx b/packages/frontend/src/containers/developerActivity/DeveloperActivityContainer.tsx index cd308fdc1..a172f431e 100644 --- a/packages/frontend/src/containers/developerActivity/DeveloperActivityContainer.tsx +++ b/packages/frontend/src/containers/developerActivity/DeveloperActivityContainer.tsx @@ -15,14 +15,18 @@ const DeveloperActivityContainer: FC = ({ const widgetHeight = useWidgetHeight(moduleData); // This widget is coin-scoped: the coin is taken from the widget's included - // tags. We use the first pinned tag as the target coin. + // tags (the same tags the search bar applies to every widget). Since the + // developer-activity endpoint accepts a single coin, we use the most + // recently added tag as the target coin. The search bar appends tags to the + // end of the list (see views::addKeywordToViewWidgets), so the last tag is + // the one the user most recently searched for. const tagsSettings = moduleData.settings.filter( (s) => s.widget_setting.setting.slug === EWidgetSettingsRegistry.IncludedTags ); const tags = tagsSettings[0]?.tags; - const coin = tags?.[0]?.slug; + const coin = tags?.[tags.length - 1]?.slug; const pollingInterval = (moduleData.widget.refresh_interval || POLLING_INTERVAL) * 1000;