From 9e6582d02f4ef1007d5707e3350d6720ea0d9241 Mon Sep 17 00:00:00 2001 From: Adetoye Adewoye Date: Fri, 15 Aug 2025 10:14:24 +0100 Subject: [PATCH 1/4] add vote-curve api --- package.json | 2 ++ src/app/api/v1/curves/route.ts | 38 +++++++++++++++++++++++++++++++ src/app/api/v1/subsquidQueries.ts | 13 +++++++++++ src/global/networkConstants.ts | 2 +- yarn.lock | 17 ++++++++++++++ 5 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 src/app/api/v1/curves/route.ts diff --git a/package.json b/package.json index ef3bbf9..91ab4c8 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@urql/core": "^4.1.4", "@urql/exchange-graphcache": "^6.3.3", "algoliasearch": "4.17.1", + "chart.js": "^4.5.0", "classnames": "^2.3.2", "dayjs": "^1.11.10", "fetch-ponyfill": "^7.1.0", @@ -58,6 +59,7 @@ "next-themes": "^0.2.1", "next-usequerystate": "^1.10.2", "react": "^18", + "react-chartjs-2": "^5.3.0", "react-dom": "^18", "react-hook-form": "^7.47.0", "react-json-view": "^1.21.3", diff --git a/src/app/api/v1/curves/route.ts b/src/app/api/v1/curves/route.ts new file mode 100644 index 0000000..7479a45 --- /dev/null +++ b/src/app/api/v1/curves/route.ts @@ -0,0 +1,38 @@ +// Copyright 2019-2025 @polkassembly/fellowship authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { NextRequest, NextResponse } from 'next/server'; +import { GET_CURVE_DATA_BY_INDEX } from '@/app/api/v1/subsquidQueries'; +import { urqlClient } from '@/services/urqlClient'; +import getNetworkFromHeaders from '@/app/api/api-utils/getNetworkFromHeaders'; +import getReqBody from '@/app/api/api-utils/getReqBody'; +import withErrorHandling from '@/app/api/api-utils/withErrorHandling'; +import { headers } from 'next/headers'; +import MESSAGES from '@/global/messages'; +import { APIError } from '@/global/exceptions'; +import { API_ERROR_CODE } from '@/global/constants/errorCodes'; + +export const POST = withErrorHandling(async (req: NextRequest) => { + const { blockGte, postId } = await getReqBody(req); + + if (!blockGte || !postId || isNaN(postId) || isNaN(blockGte)) throw new APIError(`${MESSAGES.INVALID_PARAMS_ERROR}`, 500, API_ERROR_CODE.INVALID_PARAMS_ERROR); + + const headersList = headers(); + const network = getNetworkFromHeaders(headersList); + + const gqlClient = urqlClient(network); + + const variables = { + block_gte: Number(blockGte), + index_eq: Number(postId) + }; + + const { data, error } = await gqlClient.query(GET_CURVE_DATA_BY_INDEX, variables).toPromise(); + + if (error) throw new APIError(`${error || MESSAGES.SUBSQUID_FETCH_ERROR}`, 500, API_ERROR_CODE.SUBSQUID_FETCH_ERROR); + + const curveData = data?.curveData || []; + + return NextResponse.json(curveData); +}); diff --git a/src/app/api/v1/subsquidQueries.ts b/src/app/api/v1/subsquidQueries.ts index 02875c8..032ddc7 100644 --- a/src/app/api/v1/subsquidQueries.ts +++ b/src/app/api/v1/subsquidQueries.ts @@ -473,3 +473,16 @@ export const GET_RANK_ACTIVITY = gql` } } `; + +export const GET_CURVE_DATA_BY_INDEX = gql` + query CurveDataByIndex($index_eq: Int, $block_gte: Int, $limit: Int = 1000) { + curveData(limit: $limit, where: { index_eq: $index_eq, block_gte: $block_gte }, orderBy: block_ASC) { + approvalPercent + block + id + index + supportPercent + timestamp + } + } +`; diff --git a/src/global/networkConstants.ts b/src/global/networkConstants.ts index 98a0ecd..e146169 100644 --- a/src/global/networkConstants.ts +++ b/src/global/networkConstants.ts @@ -13,7 +13,7 @@ const networkConstants: NetworkConstants = { category: 'polkadot', logoUrl: '/parachain-logos/polkadot-logo.svg', ss58Format: 0, - subsquidUrl: 'https://squid.subsquid.io/collectives-polkassembly/graphql', + subsquidUrl: 'https://polkassembly.squids.live/collectives-polkassembly@v4/api/graphql', tokenDecimals: 10, tokenSymbol: 'DOT', subscanBaseUrl: 'https://kusama.api.subscan.io', diff --git a/yarn.lock b/yarn.lock index f84ade8..d100457 100644 --- a/yarn.lock +++ b/yarn.lock @@ -478,6 +478,11 @@ dependencies: lodash "^4.17.21" +"@kurkle/color@^0.3.0": + version "0.3.4" + resolved "https://registry.yarnpkg.com/@kurkle/color/-/color-0.3.4.tgz#4d4ff677e1609214fc71c580125ddddd86abcabf" + integrity sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w== + "@next/env@14.2.20": version "14.2.20" resolved "https://registry.yarnpkg.com/@next/env/-/env-14.2.20.tgz#0be2cc955f4eb837516e7d7382284cd5bc1d5a02" @@ -3537,6 +3542,13 @@ character-reference-invalid@^2.0.0: resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz#85c66b041e43b47210faf401278abf808ac45cb9" integrity sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw== +chart.js@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-4.5.0.tgz#11a1ef6c4befc514b1b0b613ebac226c4ad2740b" + integrity sha512-aYeC/jDgSEx8SHWZvANYMioYMZ2KX02W6f6uVfyteuCGcadDLcYVHdfdygsTQkQ4TKn5lghoojAsPj5pu0SnvQ== + dependencies: + "@kurkle/color" "^0.3.0" + chokidar@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" @@ -7072,6 +7084,11 @@ react-base16-styling@^0.6.0: lodash.flow "^3.3.0" pure-color "^1.2.0" +react-chartjs-2@^5.3.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/react-chartjs-2/-/react-chartjs-2-5.3.0.tgz#2d3286339a742bc7f77b5829c33ebab215f714cc" + integrity sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw== + react-copy-to-clipboard@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/react-copy-to-clipboard/-/react-copy-to-clipboard-5.1.0.tgz#09aae5ec4c62750ccb2e6421a58725eabc41255c" From 11a66fae36dca93673d083bf21f51c62a92b9755 Mon Sep 17 00:00:00 2001 From: Adetoye Adewoye Date: Mon, 18 Aug 2025 05:35:41 +0100 Subject: [PATCH 2/4] add rendering for votes curve data --- .../VoteCurvesData/VoteCurves.tsx | 297 ++++++++++++++++++ .../VoteCurvesData/VoteCurvesData.tsx | 56 ++++ .../VoteCurvesData/VoteCurvesDataWrapper.tsx | 96 ++++++ .../VoteCurvesData/VoteCurvesDetails.tsx | 50 +++ .../Post/GovernanceSidebar/index.tsx | 3 + src/global/types.ts | 25 ++ src/utils/trackCurvesUtils.ts | 103 ++++++ 7 files changed, 630 insertions(+) create mode 100644 src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurves.tsx create mode 100644 src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurvesData.tsx create mode 100644 src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurvesDataWrapper.tsx create mode 100644 src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurvesDetails.tsx create mode 100644 src/utils/trackCurvesUtils.ts diff --git a/src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurves.tsx b/src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurves.tsx new file mode 100644 index 0000000..4a0df18 --- /dev/null +++ b/src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurves.tsx @@ -0,0 +1,297 @@ +// Copyright 2019-2025 @polkassembly/fellowship authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { useEffect, useMemo } from 'react'; +import { Line } from 'react-chartjs-2'; +import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, ChartOptions, ChartData, Point } from 'chart.js'; +import { ProposalType, IStatusHistoryItem, IVoteCurve } from '@/global/types'; +import dayjs from '@/services/dayjs-init'; +import { Network } from '@/global/types'; +import { getTrackFunctions } from '@/utils/trackCurvesUtils'; + +ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend); + +interface Props { + voteCurveData: IVoteCurve[]; + trackName: ProposalType; + timeline?: IStatusHistoryItem[]; + createdAt?: Date; + setThresholdValues?: (values: { approvalThreshold: number; supportThreshold: number }) => void; +} + +function formatHoursAndDays(num: number, unit: 'day' | 'hr') { + if (num === 1) { + return `${num}${unit}`; + } + return `${num}${unit}s`; +} + +function convertGraphPoint(value?: number) { + if (!value) { + return '--'; + } + + return `${Number(value).toFixed(2)}%`; +} + +function VoteCurves({ voteCurveData, trackName, timeline, createdAt, setThresholdValues }: Props) { + const network: Network = 'collectives' as Network; + + const { approvalCalc, supportCalc } = getTrackFunctions({ network, trackName }); + + const chartData: ChartData<'line', (number | Point | null)[]> = useMemo(() => { + if (!voteCurveData || voteCurveData.length === 0) { + return { + datasets: [], + labels: [] + }; + } + + // Simplified track info for fellowship referendums + const trackInfo = { + decisionPeriod: 100800 // 7 days in blocks + }; + + const labels: number[] = []; + const supportData: { x: number; y: number }[] = []; + const approvalData: { x: number; y: number }[] = []; + + const approvalThresholdData: { x: number; y: number }[] = []; + const supportThresholdData: { x: number; y: number }[] = []; + + const statusBlock = timeline?.find((s) => s?.status === 'Deciding'); + + const lastGraphPoint = voteCurveData[voteCurveData.length - 1]; + const proposalCreatedAt = dayjs(statusBlock?.timestamp || createdAt || voteCurveData[0].timestamp); + + const { decisionPeriod } = trackInfo; + + const decisionPeriodInHrs = Math.floor(dayjs.duration(decisionPeriod * 6, 'seconds').asHours()); // Assuming 6s block time + const decisionPeriodFromTimelineInHrs = dayjs(lastGraphPoint.timestamp).diff(proposalCreatedAt, 'hour'); + + if (decisionPeriodFromTimelineInHrs < decisionPeriodInHrs) { + for (let i = 0; i < decisionPeriodInHrs; i += 1) { + labels.push(i); + + if (approvalCalc) { + approvalThresholdData.push({ + x: i, + y: approvalCalc(i / decisionPeriodInHrs) * 100 + }); + } + + if (supportCalc) { + supportThresholdData.push({ + x: i, + y: supportCalc(i / decisionPeriodInHrs) * 100 + }); + } + } + } + + // Process each data point + voteCurveData.forEach((point) => { + const hour = dayjs(point.timestamp).diff(proposalCreatedAt, 'hour'); + labels.push(hour); + + if (decisionPeriodFromTimelineInHrs > decisionPeriodInHrs) { + if (approvalCalc) { + approvalThresholdData.push({ + x: hour, + y: approvalCalc(hour / decisionPeriodFromTimelineInHrs) * 100 + }); + } + if (supportCalc) { + supportThresholdData.push({ + x: hour, + y: supportCalc(hour / decisionPeriodFromTimelineInHrs) * 100 + }); + } + } + + // Add actual data points + if (point.supportPercent !== undefined) { + supportData.push({ + x: hour, + y: point.supportPercent + }); + } + + if (point.approvalPercent !== undefined) { + approvalData.push({ + x: hour, + y: point.approvalPercent + }); + } + }); + + return { + datasets: [ + { + backgroundColor: 'transparent', + borderColor: '#2ED47A', // voteAye color + borderWidth: 2, + borderDash: [4, 4], + data: approvalData, + label: 'Approval', + pointHitRadius: 10, + pointHoverRadius: 5, + pointRadius: 0, + tension: 0.1 + }, + { + backgroundColor: 'transparent', + borderColor: '#FF3C5F', // voteNay color + borderWidth: 2, + borderDash: [4, 4], + data: supportData, + label: 'Support', + pointHitRadius: 10, + pointHoverRadius: 5, + pointRadius: 0, + tension: 0.1 + }, + { + backgroundColor: 'transparent', + borderColor: '#2ED47A', // voteAye color + borderWidth: 2, + data: approvalThresholdData, + label: 'Approval Threshold', + pointHitRadius: 10, + pointHoverRadius: 5, + pointRadius: 0, + tension: 0.1 + }, + { + backgroundColor: 'transparent', + borderColor: '#FF3C5F', // voteNay color + borderWidth: 2, + data: supportThresholdData, + label: 'Support Threshold', + pointHitRadius: 10, + pointHoverRadius: 5, + pointRadius: 0, + tension: 0.1 + } + ], + labels + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [voteCurveData]); + + useEffect(() => { + if (!chartData || !chartData.datasets || chartData.datasets.length < 4) { + return; + } + + const approvalThresholdData = chartData.datasets[2].data as Point[]; + const supportThresholdData = chartData.datasets[3].data as Point[]; + const currentApproval = chartData.datasets[0].data[chartData.datasets[0].data.length - 1] as Point; + const currentSupport = chartData.datasets[1].data[chartData.datasets[1].data.length - 1] as Point; + + setThresholdValues?.({ + approvalThreshold: approvalThresholdData.find((data) => data && data?.x >= currentApproval?.x)?.y || 0, + supportThreshold: supportThresholdData.find((data) => data && data?.x >= currentSupport?.x)?.y || 0 + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chartData]); + + const chartOptions: ChartOptions<'line'> = { + animation: { + duration: 0 + }, + clip: false, + plugins: { + legend: { + display: false, + position: 'bottom' + }, + tooltip: { + callbacks: { + label(tooltipItem) { + const { dataIndex, parsed, dataset } = tooltipItem; + if (dataset.label === 'Support Threshold') { + const threshold = Number(parsed.y).toFixed(2); + const data = chartData.datasets.find((d) => d.label === 'Support'); + + const currSupport = data?.data.find((d) => (d as Point).x > dataIndex) as Point; + return `Support: ${convertGraphPoint(currSupport?.y)} / ${threshold}%`; + } + if (dataset.label === 'Approval Threshold') { + const threshold = Number(parsed.y).toFixed(2); + const data = chartData.datasets.find((d) => d.label === 'Approval'); + + const currApproval = data?.data.find((d) => (d as Point).x > dataIndex) as Point; + return `Approval: ${convertGraphPoint(currApproval?.y)} / ${threshold}%`; + } + + return ''; + }, + title(values) { + const { label } = values[0]; + const hours = Number(label); + const days = Math.floor(hours / 24); + const resultHours = hours - days * 24; + let result = `Time: ${formatHoursAndDays(hours, 'hr')}`; + if (days > 0) { + result += ` (${formatHoursAndDays(days, 'day')} ${resultHours > 0 ? formatHoursAndDays(resultHours, 'hr') : ''})`; + } + return result; + } + }, + displayColors: false, + intersect: false, + mode: 'index' + } + }, + scales: { + x: { + min: 0, + title: { + display: true, + text: 'Days' + }, + grid: { + display: false + }, + ticks: { + callback(v) { + return (Number(v) / 24).toFixed(0); + }, + stepSize: 24 + }, + type: 'linear' + }, + y: { + title: { + display: true, + text: 'Passing Percentage' + }, + max: 100, + min: 0, + ticks: { + stepSize: 10, + callback(val) { + return `${val}%`; + } + }, + grid: { + display: false + } + } + } + }; + + return ( +
+ +
+ ); +} + +export default VoteCurves; diff --git a/src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurvesData.tsx b/src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurvesData.tsx new file mode 100644 index 0000000..d4cca00 --- /dev/null +++ b/src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurvesData.tsx @@ -0,0 +1,56 @@ +// Copyright 2019-2025 @polkassembly/fellowship authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { ProposalType, IStatusHistoryItem, IVoteCurve } from '@/global/types'; +import VoteCurves from './VoteCurves'; +import LoadingSpinner from '@/components/Misc/LoadingSpinner'; +import VoteCurvesDetails from './VoteCurvesDetails'; +import { Card } from '@nextui-org/card'; + +interface Props { + trackName: ProposalType; + createdAt?: Date; + timeline?: IStatusHistoryItem[]; + setThresholdValues?: (values: { approvalThreshold: number; supportThreshold: number }) => void; + thresholdValues?: { approvalThreshold: number; supportThreshold: number } | undefined; + latestApproval: number | null; + latestSupport: number | null; + isFetching: boolean; + voteCurveData: IVoteCurve[]; +} + +// main component +function VoteCurvesData({ trackName, createdAt, timeline, setThresholdValues, thresholdValues, latestApproval, latestSupport, isFetching, voteCurveData }: Props) { + return ( + +

Vote Curves

+ +
+ {isFetching && ( +
+ +
+ )} + + +
+
+ ); +} + +export default VoteCurvesData; diff --git a/src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurvesDataWrapper.tsx b/src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurvesDataWrapper.tsx new file mode 100644 index 0000000..cb20f07 --- /dev/null +++ b/src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurvesDataWrapper.tsx @@ -0,0 +1,96 @@ +// Copyright 2019-2025 @polkassembly/fellowship authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import React, { useState, useEffect } from 'react'; +import { usePostDataContext } from '@/contexts'; +import { IVoteCurve } from '@/global/types'; +import VoteCurvesData from './VoteCurvesData'; +import nextApiClientFetch from '@/utils/nextApiClientFetch'; +import { useApiContext } from '@/contexts'; + +function VoteCurvesDataWrapper() { + const [thresholdValues, setThresholdValues] = useState<{ approvalThreshold: number; supportThreshold: number } | undefined>(undefined); + const [voteCurveData, setVoteCurveData] = useState([]); + const [isFetching, setIsFetching] = useState(false); + const [latestApproval, setLatestApproval] = useState(null); + const [latestSupport, setLatestSupport] = useState(null); + + const { postData } = usePostDataContext(); + const { network } = useApiContext(); + + const timeline = postData?.on_chain_info?.statusHistory; + + // Get the block when the proposal started deciding from timeline + const getDecidingBlock = () => { + if (!timeline || timeline.length === 0) return 0; + + const decidingStatus = timeline.find((status) => status.status === 'Deciding'); + return decidingStatus?.block || 0; + }; + + // Fetch vote curves data + const fetchVoteCurves = async () => { + if (!postData.id) return; + + setIsFetching(true); + try { + const blockGte = getDecidingBlock(); + + const { data, error } = await nextApiClientFetch({ + url: 'api/v1/curves', + data: { + blockGte, + postId: postData.id + }, + network, + isPolkassemblyAPI: false + }); + + if (error || !data) { + console.error('Failed to fetch vote curves:', error); + setVoteCurveData([]); + return; + } + + setVoteCurveData(data); + + // Get latest approval and support values + if (data.length > 0) { + const latest = data[data.length - 1]; + setLatestApproval(latest.approvalPercent); + setLatestSupport(latest.supportPercent); + } + } catch (error) { + console.error('Error fetching vote curves:', error); + setVoteCurveData([]); + } finally { + setIsFetching(false); + } + }; + + useEffect(() => { + fetchVoteCurves(); + }, [postData.id, timeline]); + + // Only render if we have vote curve data + if (voteCurveData.length === 0 && !isFetching) { + return null; + } + + return ( + + ); +} + +export default VoteCurvesDataWrapper; diff --git a/src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurvesDetails.tsx b/src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurvesDetails.tsx new file mode 100644 index 0000000..0ca888f --- /dev/null +++ b/src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurvesDetails.tsx @@ -0,0 +1,50 @@ +// Copyright 2019-2025 @polkassembly/fellowship authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +interface Props { + latestApproval: number | null; + latestSupport: number | null; + thresholdValues: { approvalThreshold: number; supportThreshold: number } | undefined; +} + +function VoteCurvesDetails({ latestApproval, latestSupport, thresholdValues }: Props) { + return ( +
+
+

+ + + Approval + + {latestApproval?.toFixed(2)}% +

+

+ + + Threshold + + {thresholdValues ? `${thresholdValues.approvalThreshold.toFixed(2)}%` : 'N/A'} +

+
+
+

+ + + Support + + {latestSupport?.toFixed(2)}% +

+

+ + + Threshold + + {thresholdValues ? `${thresholdValues.supportThreshold.toFixed(2)}%` : 'N/A'} +

+
+
+ ); +} + +export default VoteCurvesDetails; diff --git a/src/components/Post/GovernanceSidebar/index.tsx b/src/components/Post/GovernanceSidebar/index.tsx index fc76813..c90d95d 100644 --- a/src/components/Post/GovernanceSidebar/index.tsx +++ b/src/components/Post/GovernanceSidebar/index.tsx @@ -6,12 +6,15 @@ import React from 'react'; import CastVoteCard from './CastVoteCard'; import VoteInfoCard from './VoteInfoCard'; import DecisionStatusCard from './DecisionStatusCard'; +import VoteCurvesDataWrapper from './VoteCurvesData/VoteCurvesDataWrapper'; + function GovernanceSidebar() { return (
+
); } diff --git a/src/global/types.ts b/src/global/types.ts index 40eeb61..bbf8790 100644 --- a/src/global/types.ts +++ b/src/global/types.ts @@ -794,3 +794,28 @@ export interface IRecordingListingResponse { totalCount: number; recordings: IRecording[]; } + +export interface IVoteCurve { + approvalPercent: number; + supportPercent: number; + block: number; + timestamp: string; + id: string; + index: number; +} + +export interface IStatusHistoryItem { + block: number; + id: string; + status: ProposalStatus; + timestamp?: string; +} + +export enum EPostOrigin { + FELLOWSHIP_REFERENDUMS = 'fellowship_referendums' +} + +export enum EProposalStatus { + Deciding = 'Deciding', + DecisionDepositPlaced = 'DecisionDepositPlaced' +} diff --git a/src/utils/trackCurvesUtils.ts b/src/utils/trackCurvesUtils.ts new file mode 100644 index 0000000..efe8693 --- /dev/null +++ b/src/utils/trackCurvesUtils.ts @@ -0,0 +1,103 @@ +// Copyright 2019-2025 @polkassembly/fellowship authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import BigNumber from 'bignumber.js'; +import { Network, ProposalType } from '@/global/types'; + +// Simplified network details for track functions +const NETWORKS_DETAILS: Record = { + collectives: { + trackDetails: { + fellowship_referendums: { + minApproval: { + reciprocal: { + factor: 1000000000, + xOffset: 1000000000, + yOffset: 0 + } + }, + minSupport: { + reciprocal: { + factor: 1000000000, + xOffset: 1000000000, + yOffset: 0 + } + }, + decisionPeriod: 100800 // 7 days in blocks (assuming 6s block time) + } + } + } +}; + +export function makeReciprocalCurve(reciprocal: { factor: number; xOffset: number; yOffset: number }) { + if (!reciprocal) { + return null; + } + const { factor, xOffset, yOffset } = reciprocal; + return function fn(percentage: number) { + const x = percentage * 10 ** 9; + + const v = new BigNumber(factor) + .div(new BigNumber(x).plus(xOffset)) + .multipliedBy(10 ** 9) + .toFixed(0, BigNumber.ROUND_DOWN); + + const calcValue = new BigNumber(v) + .plus(yOffset) + .div(10 ** 9) + .toString(); + return BigNumber.max(calcValue, 0).toNumber(); + }; +} + +export function makeLinearCurve(linearDecreasing: { length: number; floor: number; ceil: number }) { + if (!linearDecreasing) { + return null; + } + const { length, floor, ceil } = linearDecreasing; + return function fn(percentage: number) { + const x = percentage * 10 ** 9; + + const xValue = BigNumber.min(x, length); + const slope = new BigNumber(ceil).minus(floor).dividedBy(length); + const deducted = slope.multipliedBy(xValue).toString(); + + const perbill = new BigNumber(ceil).minus(deducted).toFixed(0, BigNumber.ROUND_DOWN); + const calcValue = new BigNumber(perbill).div(10 ** 9).toString(); + return BigNumber.max(calcValue, 0).toNumber(); + }; +} + +export function getTrackFunctions({ network, trackName }: { network: Network; trackName: ProposalType }) { + const trackInfo = NETWORKS_DETAILS[network]?.trackDetails?.[trackName]; + if (!trackInfo) { + return { + approvalCalc: null, + supportCalc: null + }; + } + + let supportCalc = null; + let approvalCalc = null; + if (trackInfo) { + if (trackInfo.minApproval) { + if (trackInfo.minApproval.reciprocal) { + approvalCalc = makeReciprocalCurve(trackInfo.minApproval.reciprocal); + } else if (trackInfo.minApproval.linearDecreasing) { + approvalCalc = makeLinearCurve(trackInfo.minApproval.linearDecreasing); + } + } + if (trackInfo.minSupport) { + if (trackInfo.minSupport.reciprocal) { + supportCalc = makeReciprocalCurve(trackInfo.minSupport.reciprocal); + } else if (trackInfo.minSupport.linearDecreasing) { + supportCalc = makeLinearCurve(trackInfo.minSupport.linearDecreasing); + } + } + } + return { + approvalCalc, + supportCalc + }; +} From 4772fdf3f6687cd09f9ca4315f63cfb866ae373c Mon Sep 17 00:00:00 2001 From: Adetoye Adewoye Date: Tue, 19 Aug 2025 06:41:12 +0100 Subject: [PATCH 3/4] hide y-axis title --- .../Post/GovernanceSidebar/VoteCurvesData/VoteCurves.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurves.tsx b/src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurves.tsx index 4a0df18..d860333 100644 --- a/src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurves.tsx +++ b/src/components/Post/GovernanceSidebar/VoteCurvesData/VoteCurves.tsx @@ -265,7 +265,7 @@ function VoteCurves({ voteCurveData, trackName, timeline, createdAt, setThreshol }, y: { title: { - display: true, + display: false, text: 'Passing Percentage' }, max: 100, From 656e3b0a7b17f0df32ca95138c6336cce31146b5 Mon Sep 17 00:00:00 2001 From: Adetoye Adewoye Date: Mon, 1 Sep 2025 10:42:52 +0100 Subject: [PATCH 4/4] update graphql query url --- src/global/networkConstants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/global/networkConstants.ts b/src/global/networkConstants.ts index e146169..2036dcb 100644 --- a/src/global/networkConstants.ts +++ b/src/global/networkConstants.ts @@ -13,7 +13,7 @@ const networkConstants: NetworkConstants = { category: 'polkadot', logoUrl: '/parachain-logos/polkadot-logo.svg', ss58Format: 0, - subsquidUrl: 'https://polkassembly.squids.live/collectives-polkassembly@v4/api/graphql', + subsquidUrl: 'https://polkassembly.squids.live/collectives-polkassembly@v3/api/graphql', tokenDecimals: 10, tokenSymbol: 'DOT', subscanBaseUrl: 'https://kusama.api.subscan.io',