Skip to content

Commit

Permalink
feat(governance): add term progress with circular progress
Browse files Browse the repository at this point in the history
  • Loading branch information
fedosov committed Jan 11, 2025
1 parent 1fe8cd3 commit e7b645c
Show file tree
Hide file tree
Showing 5 changed files with 147 additions and 27 deletions.
57 changes: 57 additions & 0 deletions app/components/common/CircularProgress.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { Intent } from "@blueprintjs/core";

interface CircularProgressProps {
value: number; // 0 to 1
size?: number;
strokeWidth?: number;
intent?: Intent;
}

export function CircularProgress({ value, size = 16, strokeWidth = 2, intent = Intent.PRIMARY }: CircularProgressProps) {
const radius = (size - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
const offset = circumference - value * circumference;

const getIntentColor = (intent: Intent) => {
switch (intent) {
case Intent.PRIMARY:
return "var(--blue-500, #106ba3)";
case Intent.SUCCESS:
return "var(--green-500, #0d8050)";
case Intent.WARNING:
return "var(--orange-500, #bf7326)";
case Intent.DANGER:
return "var(--red-500, #c23030)";
default:
return "var(--blue-500, #106ba3)";
}
};

return (
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="transform -rotate-90">
{/* Background circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
className="text-gray-200 dark:text-gray-700"
/>
{/* Progress circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={getIntentColor(intent)}
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
style={{ transition: "stroke-dashoffset 0.2s ease" }}
/>
</svg>
);
}
23 changes: 3 additions & 20 deletions app/components/governance/MotionDetails.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
import { Card, Classes, Tag, Intent, Button, Icon, ProgressBar } from "@blueprintjs/core";
import { Card, Tag, Intent, Button, Icon, ProgressBar } from "@blueprintjs/core";
import { useTranslation } from "react-i18next";
import { DeriveCollectiveProposal } from "@polkadot/api-derive/types";
import { AccountName } from "app/components/common/AccountName";
import { formatBalance } from "@polkadot/util";
import { BountyDetails } from "./BountyDetails";
import { useApi } from "app/components/Api";
import { useEffect, useState } from "react";
import { formatDuration } from "app/utils/time";
import { formatTimeLeft } from "app/utils/time";
import { useLocation } from "@remix-run/react";
import useToaster from "app/hooks/useToaster";

// Constants
const BLOCK_TIME_SECONDS = 60;
const DEFAULT_VOTING_PERIOD = 2880; // About 2 days with 60-second blocks

function TimeRemaining({ motion }: { motion: DeriveCollectiveProposal }) {
Expand Down Expand Up @@ -96,23 +95,7 @@ function TimeRemaining({ motion }: { motion: DeriveCollectiveProposal }) {
return null;
}

const formatTimeLeft = (blocks: number) => {
const seconds = blocks * BLOCK_TIME_SECONDS;
const hours = Math.floor(seconds / 3600);
const days = Math.floor(hours / 24);
const remainingHours = hours % 24;

if (days > 0) {
return `${days}d ${remainingHours}h left`;
}
if (hours > 0) {
return `${hours}h left`;
}
const minutes = Math.ceil(seconds / 60);
return `${minutes}m left`;
};

const display = timeRemaining > 0 ? formatTimeLeft(timeRemaining) : "(ended)";
const display = timeRemaining > 0 ? formatTimeLeft(timeRemaining) + " left" : "(ended)";

return (
<div className="flex items-center">
Expand Down
66 changes: 64 additions & 2 deletions app/routes/governance.members.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { useApi } from "../components/Api";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Card, Elevation, Spinner, Tag, Intent, HTMLTable, Classes } from "@blueprintjs/core";
import { Spinner, Tag, Intent, HTMLTable, Classes } from "@blueprintjs/core";
import type { AccountId } from "@polkadot/types/interfaces";
import type { DeriveElectionsInfo, DeriveCouncilVotes } from "@polkadot/api-derive/types";
import { formatBalance } from "@polkadot/util";
import { AccountName, extractIdentity } from "app/components/common/AccountName";
import { formatTimeLeft } from "app/utils/time";
import { CircularProgress } from "app/components/common/CircularProgress";

interface CouncilMember {
address: string;
Expand Down Expand Up @@ -41,6 +43,12 @@ export default function GovernanceMembers() {
const [loading, setLoading] = useState(true);
const [electionsInfo, setElectionsInfo] = useState<DeriveElectionsInfo | null>(null);
const [allVotes, setAllVotes] = useState<Record<string, AccountId[]>>({});
const [termProgress, setTermProgress] = useState<{
current: number;
total: number;
blockNumber: number;
mod: number;
} | null>(null);

useEffect(() => {
if (!api) return;
Expand All @@ -64,6 +72,38 @@ export default function GovernanceMembers() {
.catch(console.error);
}, [api]);

useEffect(() => {
async function fetchTermProgress() {
if (!api) return;

try {
// Try all possible module paths for term duration
const termDuration =
api.consts.elections?.termDuration || api.consts.phragmenElection?.termDuration || api.consts.electionsPhragmen?.termDuration;

const blockNumber = await api.derive.chain.bestNumber();

if (termDuration) {
const total = parseInt(termDuration.toString());
const current = parseInt(blockNumber.toString());
const mod = current % total;
const remaining = total - mod;

setTermProgress({
current: remaining,
total,
blockNumber: current,
mod,
});
}
} catch (error) {
console.error("Error calculating term progress:", error);
}
}

fetchTermProgress();
}, [api]);

useEffect(() => {
async function fetchCouncilData() {
if (!api || !electionsInfo) return;
Expand Down Expand Up @@ -110,9 +150,31 @@ export default function GovernanceMembers() {
return <Spinner />;
}

const termProgressPercentage = termProgress ? ((termProgress.total - termProgress.current) / termProgress.total) * 100 : 0;
const remainingBlocks = termProgress ? termProgress.total - termProgress.mod : 0;
const timeLeft = formatTimeLeft(remainingBlocks);
const totalTime = termProgress ? formatTimeLeft(termProgress.total) : "";

return (
<div>
<h2 className={`${Classes.HEADING} mb-4`}>{t("governance.council_members")}</h2>
<div className="flex justify-between items-center mb-4">
<h2 className={Classes.HEADING}>{t("governance.council_members")}</h2>
{termProgress && (
<div className="flex items-center gap-4">
<div className="flex flex-col text-right">
<span className="text-sm text-gray-600 dark:text-gray-400">
{t("governance.term_progress")} ({termProgressPercentage.toFixed(1)}%)
</span>
<div className="flex items-center justify-end gap-2">
<CircularProgress value={termProgressPercentage / 100} size={20} strokeWidth={3} intent={Intent.PRIMARY} />
<span className="text-sm font-medium">
{timeLeft} {t("governance.remaining")} <span className="text-gray-500">/ {totalTime}</span>
</span>
</div>
</div>
</div>
)}
</div>
<HTMLTable className="w-full" striped={true}>
<thead>
<tr>
Expand Down
5 changes: 4 additions & 1 deletion app/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,10 @@
"bounty_next_action_update_overdue": "Update from curator is overdue",
"bounty_next_action_pending_payout": "Waiting for payout",
"bounty_next_action_claim_payout": "Claim payout",
"no_motions": "No motions available."
"no_motions": "No motions available.",
"term_progress": "Term Progress",
"blocks_remaining": "blocks remaining",
"remaining": "remaining"
}
}
}
23 changes: 19 additions & 4 deletions app/utils/time.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
export function formatDuration(secondsDuration) {
/**
* Return a string representing the duration in seconds in format like "54 seconds" or "4 minutes 3 seconds"
*/
export const BLOCK_TIME_SECONDS = 60;

export function formatTimeLeft(blocks: number): string {
const totalSeconds = blocks * BLOCK_TIME_SECONDS;
const totalHours = totalSeconds / 3600;
const days = Math.floor(totalHours / 24);
const remainingHours = Math.floor(totalHours % 24);

if (days > 0) {
return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`;
}
if (totalHours >= 1) {
return `${Math.floor(totalHours)}h`;
}
const minutes = Math.ceil(totalSeconds / 60);
return `${minutes}m`;
}

export function formatDuration(secondsDuration: number): string {
const hours = Math.floor(secondsDuration / 3600);
const minutes = Math.floor((secondsDuration - hours * 3600) / 60);
const seconds = secondsDuration - hours * 3600 - minutes * 60;
Expand Down

0 comments on commit e7b645c

Please sign in to comment.