diff --git a/app/network/metrics/components/CumulativeMetricCard.tsx b/app/network/metrics/components/CumulativeMetricCard.tsx index 806d755271..eaeff908f2 100644 --- a/app/network/metrics/components/CumulativeMetricCard.tsx +++ b/app/network/metrics/components/CumulativeMetricCard.tsx @@ -9,6 +9,7 @@ import { formatNumberWithCommas, formatPercent, } from "../utils/formatNumbers"; +import MetricSparkline from "./MetricSparkline"; interface CumulativeMetricCardProps { readonly title: string; @@ -19,6 +20,9 @@ interface CumulativeMetricCardProps { readonly accentColor: string; readonly unit?: string; readonly href?: string; + readonly sparklineData?: number[] | undefined; + readonly sparklineColor?: string | undefined; + readonly sparklineDates?: number[] | undefined; } function ChangeRow({ @@ -69,6 +73,9 @@ export default function CumulativeMetricCard({ accentColor, unit = "", href, + sparklineData, + sparklineColor, + sparklineDates, }: CumulativeMetricCardProps) { const total = dailyData.current.valueCount; const daily24hChange = @@ -78,7 +85,7 @@ export default function CumulativeMetricCard({ const content = (

@@ -143,6 +150,13 @@ export default function CumulativeMetricCard({ />

+ {sparklineData && sparklineColor && ( + + )} ); @@ -150,7 +164,7 @@ export default function CumulativeMetricCard({ return ( {content} diff --git a/app/network/metrics/components/MetricCard.tsx b/app/network/metrics/components/MetricCard.tsx index 39b13c5755..4673cc42cb 100644 --- a/app/network/metrics/components/MetricCard.tsx +++ b/app/network/metrics/components/MetricCard.tsx @@ -8,6 +8,7 @@ import { formatNumberWithCommas, formatPercent, } from "../utils/formatNumbers"; +import MetricSparkline from "./MetricSparkline"; interface MetricCardProps { readonly title: string; @@ -19,6 +20,9 @@ interface MetricCardProps { readonly useValueCount?: boolean; readonly suffix?: string | undefined; readonly href?: string; + readonly sparklineData?: number[] | undefined; + readonly sparklineColor?: string | undefined; + readonly sparklineDates?: number[] | undefined; } function StatBlock({ @@ -97,6 +101,9 @@ export default function MetricCard({ useValueCount = false, suffix, href, + sparklineData, + sparklineColor, + sparklineDates, }: MetricCardProps) { const getCount = (data: MetricData, period: "current" | "previous") => useValueCount ? data[period].valueCount : data[period].eventCount; @@ -106,7 +113,7 @@ export default function MetricCard({ const content = (

@@ -144,6 +151,13 @@ export default function MetricCard({ suffix={suffix} />

+ {sparklineData && sparklineColor && ( + + )}
); @@ -151,7 +165,7 @@ export default function MetricCard({ return ( {content} diff --git a/app/network/metrics/components/MetricSparkline.tsx b/app/network/metrics/components/MetricSparkline.tsx new file mode 100644 index 0000000000..11f45acbb5 --- /dev/null +++ b/app/network/metrics/components/MetricSparkline.tsx @@ -0,0 +1,49 @@ +import CustomTooltip from "@/components/utils/tooltip/CustomTooltip"; +import { formatCompactNumber } from "../utils/formatNumbers"; + +interface MetricSparklineProps { + readonly data: number[]; + readonly color: string; + readonly dates?: number[]; +} + +function formatDate(timestamp: number): string { + return new Date(timestamp).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); +} + +export default function MetricSparkline({ + data, + color, + dates, +}: MetricSparklineProps) { + if (data.length === 0) return null; + + const maxValue = Math.max(...data); + const reversed = [...data].reverse(); + const reversedDates = dates ? [...dates].reverse() : undefined; + + return ( +
+ {reversed.map((value, index) => { + const height = maxValue > 0 ? (value / maxValue) * 100 : 0; + const date = reversedDates?.[index]; + const tooltipContent = + typeof date === "number" + ? `${formatDate(date)}: ${formatCompactNumber(value)}` + : formatCompactNumber(value); + + return ( + +
+ + ); + })} +
+ ); +} diff --git a/app/network/metrics/page.client.tsx b/app/network/metrics/page.client.tsx index 19fac3ffd1..255e313215 100644 --- a/app/network/metrics/page.client.tsx +++ b/app/network/metrics/page.client.tsx @@ -2,6 +2,7 @@ import { useSetTitle } from "@/contexts/TitleContext"; import { useCommunityMetrics } from "@/hooks/useCommunityMetrics"; +import { useCommunityMetricsSeries } from "@/hooks/useCommunityMetricsSeries"; import { useMintMetrics } from "@/hooks/useMintMetrics"; import CumulativeMetricCard from "./components/CumulativeMetricCard"; import MetricCard from "./components/MetricCard"; @@ -30,10 +31,12 @@ export default function MetricsPageClient() { const dailyQuery = useCommunityMetrics("DAY"); const weeklyQuery = useCommunityMetrics("WEEK"); const mintQuery = useMintMetrics(50); + const seriesQuery = useCommunityMetricsSeries(); const isLoading = dailyQuery.isLoading || weeklyQuery.isLoading || mintQuery.isLoading; const error = dailyQuery.error ?? weeklyQuery.error ?? mintQuery.error; + const series = seriesQuery.data; return (
@@ -68,6 +71,9 @@ export default function MetricsPageClient() { icon={} iconBgColor="tw-bg-purple-500" accentColor="tw-text-purple-400" + sparklineData={series?.distinctDroppers} + sparklineColor="tw-bg-purple-500" + sparklineDates={series?.stepsStartTimes} /> } iconBgColor="tw-bg-blue-500" accentColor="tw-text-blue-400" + sparklineData={series?.dropsCreated} + sparklineColor="tw-bg-blue-500" + sparklineDates={series?.stepsStartTimes} /> } iconBgColor="tw-bg-orange-500" accentColor="tw-text-orange-400" + sparklineData={series?.mainStageSubmissions} + sparklineColor="tw-bg-orange-500" + sparklineDates={series?.stepsStartTimes} /> } iconBgColor="tw-bg-emerald-500" accentColor="tw-text-emerald-400" + sparklineData={series?.mainStageDistinctVoters} + sparklineColor="tw-bg-emerald-500" + sparklineDates={series?.stepsStartTimes} /> } iconBgColor="tw-bg-violet-500" accentColor="tw-text-violet-400" + sparklineData={series?.profileCount} + sparklineColor="tw-bg-violet-500" + sparklineDates={series?.stepsStartTimes} /> } iconBgColor="tw-bg-pink-500" accentColor="tw-text-pink-400" + sparklineData={series?.activeIdentities} + sparklineColor="tw-bg-pink-500" + sparklineDates={series?.stepsStartTimes} /> } iconBgColor="tw-bg-indigo-500" accentColor="tw-text-indigo-400" + sparklineData={series?.consolidationsFormed} + sparklineColor="tw-bg-indigo-500" + sparklineDates={series?.stepsStartTimes} />
)} diff --git a/components/react-query-wrapper/ReactQueryWrapper.tsx b/components/react-query-wrapper/ReactQueryWrapper.tsx index 8be51c9b30..c0eb2855d2 100644 --- a/components/react-query-wrapper/ReactQueryWrapper.tsx +++ b/components/react-query-wrapper/ReactQueryWrapper.tsx @@ -102,6 +102,7 @@ export enum QueryKey { WAVE_OUTCOME_DISTRIBUTION = "WAVE_OUTCOME_DISTRIBUTION", WAVE_OUTCOME_DISTRIBUTION_PAGE = "WAVE_OUTCOME_DISTRIBUTION_PAGE", COMMUNITY_METRICS = "COMMUNITY_METRICS", + COMMUNITY_METRICS_SERIES = "COMMUNITY_METRICS_SERIES", MINT_METRICS = "MINT_METRICS", } @@ -301,13 +302,11 @@ const createReactQueryContextValue = ( const existingData = queryClient.getQueryData(queryKey); if (existingData) { return; - } else { - // If there's no existing data, set the initial data - queryClient.setQueryData>(queryKey, { + } + queryClient.setQueryData>(queryKey, { pages: [wavesOverview], pageParams: [undefined], }); - } }; const setWaveDrops = ({ @@ -330,13 +329,11 @@ const createReactQueryContextValue = ( const existingData = queryClient.getQueryData(queryKey); if (existingData) { return; - } else { - // If there's no existing data, set the initial data - queryClient.setQueryData>(queryKey, { - pages: [waveDrops], - pageParams: [undefined], - }); } + queryClient.setQueryData>(queryKey, { + pages: [waveDrops], + pageParams: [undefined], + }); }; const setProfileProxy = (profileProxy: ApiProfileProxy) => { @@ -559,7 +556,7 @@ const createReactQueryContextValue = ( rater: rater.toLowerCase(), }); } - if (profileProxy?.created_by?.handle && profileProxy.granted_to?.handle) { + if (profileProxy?.created_by.handle && profileProxy.granted_to.handle) { invalidateQueries({ key: QueryKey.PROFILE, values: [ @@ -571,12 +568,12 @@ const createReactQueryContextValue = ( key: QueryKey.PROFILE_RATERS, values: [ { - handleOrWallet: profileProxy.created_by?.handle, + handleOrWallet: profileProxy.created_by.handle, matter: RateMatter.NIC, given: false, }, { - handleOrWallet: profileProxy.granted_to?.handle, + handleOrWallet: profileProxy.granted_to.handle, matter: RateMatter.NIC, given: false, }, @@ -643,7 +640,7 @@ const createReactQueryContextValue = ( }); } - if (profileProxy?.created_by?.handle && profileProxy.granted_to?.handle) { + if (profileProxy?.created_by.handle && profileProxy.granted_to.handle) { invalidateQueries({ key: QueryKey.PROFILE, values: [ diff --git a/generated/models/ApiCommunityMetricsSeries.ts b/generated/models/ApiCommunityMetricsSeries.ts new file mode 100644 index 0000000000..fa7078f780 --- /dev/null +++ b/generated/models/ApiCommunityMetricsSeries.ts @@ -0,0 +1,114 @@ +// @ts-nocheck +/** + * 6529.io API + * This is the API interface description. Brief terminology overview and an authentication example can be found at https://6529.io/about/api. + * + * OpenAPI spec version: 1.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { HttpFile } from '../http/http'; + +export class ApiCommunityMetricsSeries { + 'steps_start_times': Array; + 'drops_created': Array; + 'distinct_droppers': Array; + 'main_stage_submissions': Array; + 'main_stage_distinct_voters': Array; + 'main_stage_votes': Array; + 'network_tdh': Array; + 'tdh_on_main_stage_submissions': Array; + 'consolidations_formed': Array; + 'xtdh_granted': Array; + 'active_identities': Array; + 'profile_count': Array; + + static readonly discriminator: string | undefined = undefined; + + static readonly mapping: {[index: string]: string} | undefined = undefined; + + static readonly attributeTypeMap: Array<{name: string, baseName: string, type: string, format: string}> = [ + { + "name": "steps_start_times", + "baseName": "steps_start_times", + "type": "Array", + "format": "int64" + }, + { + "name": "drops_created", + "baseName": "drops_created", + "type": "Array", + "format": "int64" + }, + { + "name": "distinct_droppers", + "baseName": "distinct_droppers", + "type": "Array", + "format": "int64" + }, + { + "name": "main_stage_submissions", + "baseName": "main_stage_submissions", + "type": "Array", + "format": "int64" + }, + { + "name": "main_stage_distinct_voters", + "baseName": "main_stage_distinct_voters", + "type": "Array", + "format": "int64" + }, + { + "name": "main_stage_votes", + "baseName": "main_stage_votes", + "type": "Array", + "format": "int64" + }, + { + "name": "network_tdh", + "baseName": "network_tdh", + "type": "Array", + "format": "int64" + }, + { + "name": "tdh_on_main_stage_submissions", + "baseName": "tdh_on_main_stage_submissions", + "type": "Array", + "format": "int64" + }, + { + "name": "consolidations_formed", + "baseName": "consolidations_formed", + "type": "Array", + "format": "int64" + }, + { + "name": "xtdh_granted", + "baseName": "xtdh_granted", + "type": "Array", + "format": "int64" + }, + { + "name": "active_identities", + "baseName": "active_identities", + "type": "Array", + "format": "int64" + }, + { + "name": "profile_count", + "baseName": "profile_count", + "type": "Array", + "format": "int64" + } ]; + + static getAttributeTypeMap() { + return ApiCommunityMetricsSeries.attributeTypeMap; + } + + public constructor() { + } +} diff --git a/generated/models/ObjectSerializer.ts b/generated/models/ObjectSerializer.ts index 1415ba9d91..1de5ad52dc 100644 --- a/generated/models/ObjectSerializer.ts +++ b/generated/models/ObjectSerializer.ts @@ -31,6 +31,7 @@ export * from '../models/ApiCommunityMembersSortOption'; export * from '../models/ApiCommunityMetric'; export * from '../models/ApiCommunityMetricSample'; export * from '../models/ApiCommunityMetrics'; +export * from '../models/ApiCommunityMetricsSeries'; export * from '../models/ApiCompleteMultipartUploadRequest'; export * from '../models/ApiCompleteMultipartUploadRequestPart'; export * from '../models/ApiCompleteMultipartUploadResponse'; @@ -283,6 +284,7 @@ import { ApiCommunityMembersSortOption } from '../models/ApiCommunityMembersSort import { ApiCommunityMetric } from '../models/ApiCommunityMetric'; import { ApiCommunityMetricSample } from '../models/ApiCommunityMetricSample'; import { ApiCommunityMetrics } from '../models/ApiCommunityMetrics'; +import { ApiCommunityMetricsSeries } from '../models/ApiCommunityMetricsSeries'; import { ApiCompleteMultipartUploadRequest } from '../models/ApiCompleteMultipartUploadRequest'; import { ApiCompleteMultipartUploadRequestPart } from '../models/ApiCompleteMultipartUploadRequestPart'; import { ApiCompleteMultipartUploadResponse } from '../models/ApiCompleteMultipartUploadResponse'; @@ -580,6 +582,7 @@ let typeMap: {[index: string]: any} = { "ApiCommunityMetric": ApiCommunityMetric, "ApiCommunityMetricSample": ApiCommunityMetricSample, "ApiCommunityMetrics": ApiCommunityMetrics, + "ApiCommunityMetricsSeries": ApiCommunityMetricsSeries, "ApiCompleteMultipartUploadRequest": ApiCompleteMultipartUploadRequest, "ApiCompleteMultipartUploadRequestPart": ApiCompleteMultipartUploadRequestPart, "ApiCompleteMultipartUploadResponse": ApiCompleteMultipartUploadResponse, diff --git a/hooks/useCommunityMetricsSeries.ts b/hooks/useCommunityMetricsSeries.ts new file mode 100644 index 0000000000..5451970649 --- /dev/null +++ b/hooks/useCommunityMetricsSeries.ts @@ -0,0 +1,78 @@ +import { useQuery } from "@tanstack/react-query"; + +import { QueryKey } from "@/components/react-query-wrapper/ReactQueryWrapper"; +import type { ApiCommunityMetricsSeries } from "@/generated/models/ApiCommunityMetricsSeries"; +import { commonApiFetch } from "@/services/api/common-api"; + +const DAYS_31_MS = 31 * 24 * 60 * 60 * 1000; + +export interface CommunityMetricsSeriesData { + readonly stepsStartTimes: number[]; + readonly dropsCreated: number[]; + readonly distinctDroppers: number[]; + readonly mainStageSubmissions: number[]; + readonly mainStageDistinctVoters: number[]; + readonly mainStageVotes: number[]; + readonly networkTdh: number[]; + readonly tdhOnMainStageSubmissions: number[]; + readonly consolidationsFormed: number[]; + readonly xtdhGranted: number[]; + readonly activeIdentities: number[]; + readonly profileCount: number[]; + readonly tdhUtilizationPercentage: number[]; +} + +function calculateTdhUtilizationPercentage( + tdhOnMainStage: number[], + networkTdh: number[] +): number[] { + return tdhOnMainStage.map((tdh, i) => { + const network = networkTdh[i] ?? 0; + return network > 0 ? (tdh / network) * 100 : 0; + }); +} + +async function fetchCommunityMetricsSeries(): Promise { + const now = Date.now(); + const since = now - DAYS_31_MS; + + const response = await commonApiFetch({ + endpoint: "community-metrics/series", + params: { + since: since.toString(), + to: now.toString(), + }, + }); + + return { + stepsStartTimes: response.steps_start_times, + dropsCreated: response.drops_created, + distinctDroppers: response.distinct_droppers, + mainStageSubmissions: response.main_stage_submissions, + mainStageDistinctVoters: response.main_stage_distinct_voters, + mainStageVotes: response.main_stage_votes, + networkTdh: response.network_tdh, + tdhOnMainStageSubmissions: response.tdh_on_main_stage_submissions, + consolidationsFormed: response.consolidations_formed, + xtdhGranted: response.xtdh_granted, + activeIdentities: response.active_identities, + profileCount: response.profile_count, + tdhUtilizationPercentage: calculateTdhUtilizationPercentage( + response.tdh_on_main_stage_submissions, + response.network_tdh + ), + }; +} + +export function useCommunityMetricsSeries() { + return useQuery({ + queryKey: [QueryKey.COMMUNITY_METRICS_SERIES], + queryFn: fetchCommunityMetricsSeries, + staleTime: 60_000, + gcTime: 300_000, + retry: 3, + retryDelay: (attemptIndex) => Math.min(1_000 * 2 ** attemptIndex, 30_000), + refetchOnWindowFocus: false, + refetchOnMount: true, + }); +} diff --git a/openapi.yaml b/openapi.yaml index d3c51d5451..57755317b3 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -330,6 +330,34 @@ paths: application/json: schema: $ref: "#/components/schemas/ApiCommunityMetrics" + /community-metrics/series: + get: + tags: + - CommunityMetrics + summary: Get community metrics series. + operationId: getCommunityMetricsSeries + parameters: + - name: since + in: query + description: Unix millis timestamp for start of series. + required: true + schema: + type: number + format: int64 + - name: to + in: query + description: Unix millis timestamp for end of series. + required: true + schema: + type: number + format: int64 + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: "#/components/schemas/ApiCommunityMetricsSeries" /community-metrics/mints: get: tags: @@ -5932,6 +5960,82 @@ components: type: number value_count: type: number + ApiCommunityMetricsSeries: + type: object + required: + - steps_start_times + - drops_created + - distinct_droppers + - main_stage_submissions + - main_stage_distinct_voters + - main_stage_votes + - network_tdh + - tdh_on_main_stage_submissions + - consolidations_formed + - xtdh_granted + - active_identities + - profile_count + properties: + steps_start_times: + type: array + items: + type: number + format: int64 + drops_created: + type: array + items: + type: number + format: int64 + distinct_droppers: + type: array + items: + type: number + format: int64 + main_stage_submissions: + type: array + items: + type: number + format: int64 + main_stage_distinct_voters: + type: array + items: + type: number + format: int64 + main_stage_votes: + type: array + items: + type: number + format: int64 + network_tdh: + type: array + items: + type: number + format: int64 + tdh_on_main_stage_submissions: + type: array + items: + type: number + format: int64 + consolidations_formed: + type: array + items: + type: number + format: int64 + xtdh_granted: + type: array + items: + type: number + format: int64 + active_identities: + type: array + items: + type: number + format: int64 + profile_count: + type: array + items: + type: number + format: int64 ApiCompleteMultipartUploadRequest: required: - upload_id