From 169cb88b10b34cc1ec5b42be5478f13512a8cd6d Mon Sep 17 00:00:00 2001 From: KingFRANKHOOD Date: Mon, 19 Oct 2026 09:41:22 +0100 Subject: [PATCH] Implement issues #1139, #1141, #1142, #1143 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #1139 — Portfolio rebalance UI with target allocation configuration - Added src/services/rebalance.service.ts: getRebalancePreview POST endpoint - Added src/utils/rebalance.utils.ts: allocation validation, sum, build helpers - Added src/hooks/useRebalance.ts: useRebalancePreviewMutation and useExecuteRebalanceMutation using execute_rebalance contract call - Added src/components/common/PortfolioRebalancePanel.tsx: per-key target % inputs, running sum validator with error when not 100%, Preview Rebalance button, swap preview results, Execute Rebalance button (disabled until valid), RebalanceExecuted toast on confirmation - Wired into ProfilePage.tsx as new "Rebalance" tab Issue #1141 — Holder milestone rewards tracker on portfolio page - Added src/services/milestoneRewards.service.ts: GET /wallets/:wallet/milestone-rewards - Added src/utils/milestoneRewards.utils.ts: resolveMilestoneState (claimed/claimable/locked), milestoneProgressPercent, hasAnyMilestones - Added src/hooks/useMilestoneRewards.ts: useMilestoneRewards query and useClaimMilestoneReward mutation (claim_milestone_reward contract call with optimistic cache update) - Added src/components/common/MilestoneRewardsSection.tsx: per-key milestone cards (30/90/180/365d), progress bars, claim buttons, claimed state with received amount and date, empty state - Wired into ProfilePage.tsx as new "Milestones" tab Issue #1142 — Key analytics chart with daily snapshot history - Added src/services/keySnapshotHistory.service.ts: GET /keys/:key_id/snapshots?range=X - Added src/utils/keySnapshotHistory.utils.ts: buildSnapshotHistoryPoints, formatMetricValue, range/metric constants - Added src/hooks/useKeySnapshotHistory.ts: useKeySnapshotHistory query (5-min stale time) - Added src/components/common/KeyAnalyticsHistoryChart.tsx: Recharts LineChart with metric tabs (Price/Volume/Holders/Market Cap), date range picker (7d/30d/90d/All Time), tooltip with exact values, connectNulls=false for sparse data, no animation flicker on metric switch via key prop - Wired into CreatorDetailPage.tsx as "Analytics History" section Issue #1143 — Key reputation score display - Added src/services/keyReputation.service.ts: GET /keys/:key_id/reputation - Added src/utils/keyReputation.utils.ts: buildReputationRadarData, clampReputationScore, reputationTier, REPUTATION_METHODOLOGY_TOOLTIP - Added src/hooks/useKeyReputation.ts: useKeyReputation query (5-min stale time) - Added src/components/common/ReputationScoreBadge.tsx: badge on discovery KeyCard showing score/1000 with tooltip explaining methodology - Added src/components/common/ReputationBreakdownPanel.tsx: Recharts RadarChart for four metrics, per-metric dl breakdown, composite score - Wired ReputationScoreBadge into DiscoveryKeyCard.tsx - Wired ReputationBreakdownPanel into CreatorDetailPage.tsx as "Reputation" section - Added queryKeys entries for snapshotHistory, reputation, milestoneRewards, rebalance --- src/components/common/DiscoveryKeyCard.tsx | 6 + .../common/KeyAnalyticsHistoryChart.tsx | 204 ++++++++++++++ .../common/MilestoneRewardsSection.tsx | 214 +++++++++++++++ .../common/PortfolioRebalancePanel.tsx | 259 ++++++++++++++++++ .../common/ReputationBreakdownPanel.tsx | 163 +++++++++++ .../common/ReputationScoreBadge.tsx | 59 ++++ src/hooks/useKeyReputation.ts | 19 ++ src/hooks/useKeySnapshotHistory.ts | 24 ++ src/hooks/useMilestoneRewards.ts | 102 +++++++ src/hooks/useRebalance.ts | 76 +++++ src/lib/queryKeys.ts | 11 + src/pages/CreatorDetailPage.tsx | 28 ++ src/pages/ProfilePage.tsx | 56 ++++ src/services/keyReputation.service.ts | 36 +++ src/services/keySnapshotHistory.service.ts | 50 ++++ src/services/milestoneRewards.service.ts | 54 ++++ src/services/rebalance.service.ts | 48 ++++ src/utils/keyReputation.utils.ts | 63 +++++ src/utils/keySnapshotHistory.utils.ts | 113 ++++++++ src/utils/milestoneRewards.utils.ts | 71 +++++ src/utils/rebalance.utils.ts | 52 ++++ 21 files changed, 1708 insertions(+) create mode 100644 src/components/common/KeyAnalyticsHistoryChart.tsx create mode 100644 src/components/common/MilestoneRewardsSection.tsx create mode 100644 src/components/common/PortfolioRebalancePanel.tsx create mode 100644 src/components/common/ReputationBreakdownPanel.tsx create mode 100644 src/components/common/ReputationScoreBadge.tsx create mode 100644 src/hooks/useKeyReputation.ts create mode 100644 src/hooks/useKeySnapshotHistory.ts create mode 100644 src/hooks/useMilestoneRewards.ts create mode 100644 src/hooks/useRebalance.ts create mode 100644 src/services/keyReputation.service.ts create mode 100644 src/services/keySnapshotHistory.service.ts create mode 100644 src/services/milestoneRewards.service.ts create mode 100644 src/services/rebalance.service.ts create mode 100644 src/utils/keyReputation.utils.ts create mode 100644 src/utils/keySnapshotHistory.utils.ts create mode 100644 src/utils/milestoneRewards.utils.ts create mode 100644 src/utils/rebalance.utils.ts diff --git a/src/components/common/DiscoveryKeyCard.tsx b/src/components/common/DiscoveryKeyCard.tsx index a2d1ffcd..baa3a22f 100644 --- a/src/components/common/DiscoveryKeyCard.tsx +++ b/src/components/common/DiscoveryKeyCard.tsx @@ -3,6 +3,7 @@ import { Link } from 'react-router'; import type { Course } from '@/services/course.service'; import CreatorInitialsAvatar from '@/components/common/CreatorInitialsAvatar'; import Change24hBadge from '@/components/common/Change24hBadge'; +import ReputationScoreBadge from '@/components/common/ReputationScoreBadge'; import { normalizeCreatorDisplayName } from '@/utils/creatorDisplayName.utils'; import { formatCreatorHandle } from '@/utils/handleDisplay.utils'; import { formatCreatorKeyPriceDisplay } from '@/utils/keyPriceDisplay.utils'; @@ -118,6 +119,11 @@ export const DiscoveryKeyCard: React.FC = ({ + + {/* Composite reputation score badge (#1143) */} +
+ +
); }; diff --git a/src/components/common/KeyAnalyticsHistoryChart.tsx b/src/components/common/KeyAnalyticsHistoryChart.tsx new file mode 100644 index 00000000..2f6cb883 --- /dev/null +++ b/src/components/common/KeyAnalyticsHistoryChart.tsx @@ -0,0 +1,204 @@ +import React, { useMemo, useState } from 'react'; +import { + ResponsiveContainer, + LineChart, + Line, + XAxis, + YAxis, + Tooltip as ChartTooltip, + CartesianGrid, +} from 'recharts'; +import { cn } from '@/lib/utils'; +import Skeleton from '@/components/ui/skeleton'; +import { useKeySnapshotHistory } from '@/hooks/useKeySnapshotHistory'; +import type { SnapshotMetric } from '@/services/keySnapshotHistory.service'; +import type { SnapshotHistoryRange } from '@/services/keySnapshotHistory.service'; +import { + SNAPSHOT_HISTORY_RANGES, + SNAPSHOT_METRICS, + buildSnapshotHistoryPoints, + formatMetricValue, + formatSnapshotAxisDate, + hasSnapshotHistoryData, + metricDataKey, + metricLabel, +} from '@/utils/keySnapshotHistory.utils'; + +export interface KeyAnalyticsHistoryChartProps { + keyId: string; + className?: string; + chartHeight?: number; +} + +const METRIC_COLOR: Record = { + price: '#fbbf24', + volume: '#60a5fa', + holders: '#34d399', + marketCap: '#f472b6', +}; + +/** + * Historical performance chart for the key detail page (#1142): daily + * price, volume, holder count, and market cap from on-chain snapshots, with + * a metric tab selector and a date range picker. Data is fetched from + * `GET /keys/:key_id/snapshots?range=X` and cached for 5 minutes. + */ +export const KeyAnalyticsHistoryChart: React.FC< + KeyAnalyticsHistoryChartProps +> = ({ keyId, className, chartHeight = 260 }) => { + const [metric, setMetric] = useState('price'); + const [range, setRange] = useState('30d'); + + const { data, isLoading, isError } = useKeySnapshotHistory(keyId, range); + + const points = useMemo( + () => buildSnapshotHistoryPoints(data, range), + [data, range] + ); + const dataKey = metricDataKey(metric); + const color = METRIC_COLOR[metric]; + + return ( +
+
+
+ {SNAPSHOT_METRICS.map(option => ( + + ))} +
+ +
+ {SNAPSHOT_HISTORY_RANGES.map(option => ( + + ))} +
+
+ + {isLoading ? ( +
+ +
+ ) : isError ? ( +

+ Couldn't load analytics history. Please try again later. +

+ ) : !hasSnapshotHistoryData(points) ? ( +

+ No snapshots available for this range yet. +

+ ) : ( +
+ + + + + + formatMetricValue(metric, Number(value)) + } + domain={[0, 'auto']} + /> + + formatSnapshotAxisDate(Number(label)) + } + formatter={value => [ + formatMetricValue(metric, Number(value)), + metricLabel(metric), + ]} + /> + + + +
+ )} +
+ ); +}; + +export default KeyAnalyticsHistoryChart; diff --git a/src/components/common/MilestoneRewardsSection.tsx b/src/components/common/MilestoneRewardsSection.tsx new file mode 100644 index 00000000..b59754e9 --- /dev/null +++ b/src/components/common/MilestoneRewardsSection.tsx @@ -0,0 +1,214 @@ +import React from 'react'; +import { Award, Lock, CheckCircle2 } from 'lucide-react'; +import Skeleton from '@/components/ui/skeleton'; +import { Button } from '@/components/ui/button'; +import { + useClaimMilestoneReward, + useMilestoneRewards, +} from '@/hooks/useMilestoneRewards'; +import { + formatDaysHeld, + hasAnyMilestones, + milestoneProgressPercent, + resolveMilestoneState, +} from '@/utils/milestoneRewards.utils'; +import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils'; +import { cn } from '@/lib/utils'; + +export interface MilestoneRewardsSectionProps { + wallet: string; + className?: string; +} + +const STATE_BADGE: Record = { + claimed: 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300', + claimable: 'border-amber-400/30 bg-amber-400/10 text-amber-300', + locked: 'border-white/10 bg-white/5 text-white/50', +}; + +/** + * Holder milestone rewards tracker for the portfolio page (#1141): per held + * key loyalty milestones (30/90/180/365 days held) with claimed/claimable/ + * locked state, a progress bar toward the next threshold, and a claim + * button for eligible rewards. + */ +export const MilestoneRewardsSection: React.FC< + MilestoneRewardsSectionProps +> = ({ wallet, className }) => { + const { data: keys = [], isLoading, isError } = useMilestoneRewards(wallet); + const claimMutation = useClaimMilestoneReward(wallet); + + if (isLoading) { + return ( +
+ + +
+ ); + } + + if (isError) { + return ( +

+ Couldn't load milestone rewards. Please try again later. +

+ ); + } + + if (!hasAnyMilestones(keys)) { + return ( +

+ No milestone rewards yet — hold a creator key for 30 days or more + to start unlocking loyalty rewards. +

+ ); + } + + return ( +
+ {keys + .filter(key => key.milestones.length > 0) + .map(key => ( +
+
+

+ {key.keyTitle ?? 'Creator key'} +

+ + {formatDaysHeld(key.daysHeld)} + +
+ +
+ {key.milestones + .slice() + .sort((a, b) => a.thresholdDays - b.thresholdDays) + .map(milestone => { + const state = resolveMilestoneState( + milestone, + key.daysHeld + ); + const progress = milestoneProgressPercent( + milestone, + key.daysHeld + ); + const isClaiming = + claimMutation.isPending && + claimMutation.variables?.keyId === key.keyId && + claimMutation.variables?.milestoneIndex === + milestone.milestoneIndex; + + return ( +
+
+ + {milestone.thresholdDays}d + + + {state === 'claimed' && ( + + )} + {state === 'locked' && ( + + )} + {state} + +
+ +
+
+
+ + {state === 'claimed' ? ( +

+ Received{' '} + + {formatDisplayKeyPrice( + milestone.receivedAmountStroops ?? 0 + )} + + {milestone.claimedAt && ( + <> + {' '} + on{' '} + {new Date( + milestone.claimedAt + ).toLocaleDateString()} + + )} +

+ ) : state === 'claimable' ? ( + + ) : ( +

+ {Math.max( + 0, + milestone.thresholdDays - key.daysHeld + )}{' '} + days to go +

+ )} +
+ ); + })} +
+
+ ))} +
+ ); +}; + +export default MilestoneRewardsSection; diff --git a/src/components/common/PortfolioRebalancePanel.tsx b/src/components/common/PortfolioRebalancePanel.tsx new file mode 100644 index 00000000..989eaf95 --- /dev/null +++ b/src/components/common/PortfolioRebalancePanel.tsx @@ -0,0 +1,259 @@ +import React, { useMemo, useState } from 'react'; +import { Scale } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import Skeleton from '@/components/ui/skeleton'; +import type { Course } from '@/services/course.service'; +import type { HeldKeyPosition } from '@/utils/portfolioValue.utils'; +import type { RebalancePreview } from '@/services/rebalance.service'; +import { + useExecuteRebalanceMutation, + useRebalancePreviewMutation, +} from '@/hooks/useRebalance'; +import { + buildAllocationInputs, + formatAllocationSum, + formatSwapDelta, + isAllocationTotalValid, + sumTargetAllocations, +} from '@/utils/rebalance.utils'; +import { formatDisplayKeyPrice } from '@/utils/keyPriceDisplay.utils'; +import { calculatePortfolioValue } from '@/utils/portfolioValue.utils'; +import showToast from '@/utils/toast.util'; +import { cn } from '@/lib/utils'; + +export interface PortfolioRebalancePanelProps { + wallet: string; + positions: HeldKeyPosition[]; + creators?: Course[]; + className?: string; +} + +/** + * Portfolio rebalance panel (#1139): lets the user configure a target + * allocation percentage for each held key, validates the total sums to + * 100%, previews the swaps `get_rebalance_preview` would require, and + * executes them atomically via `execute_rebalance`. + */ +export const PortfolioRebalancePanel: React.FC< + PortfolioRebalancePanelProps +> = ({ wallet, positions, creators = [], className }) => { + const portfolioValue = useMemo( + () => calculatePortfolioValue(positions), + [positions] + ); + + const initialAllocations = useMemo(() => { + const totalStroops = portfolioValue.totalStroops || 1; + return positions.map(position => { + const valueStroops = + (position.quantity ?? 0) * (position.priceStroops ?? 0); + const currentPercent = + totalStroops > 0 + ? Math.round((valueStroops / totalStroops) * 10000) / 100 + : 0; + return { keyId: position.creatorId, targetPercent: currentPercent }; + }); + }, [positions, portfolioValue.totalStroops]); + + const [allocations, setAllocations] = useState(initialAllocations); + const [preview, setPreview] = useState(null); + + const previewMutation = useRebalancePreviewMutation(wallet); + const executeMutation = useExecuteRebalanceMutation(wallet); + + const totalPercent = sumTargetAllocations(allocations); + const isValidTotal = isAllocationTotalValid(allocations); + + const handlePercentChange = (keyId: string, value: string) => { + const parsed = Number(value); + setPreview(null); + setAllocations(prev => + prev.map(row => + row.keyId === keyId + ? { ...row, targetPercent: Number.isFinite(parsed) ? parsed : 0 } + : row + ) + ); + }; + + const handlePreview = async () => { + const inputs = buildAllocationInputs(allocations); + try { + const result = await previewMutation.mutateAsync(inputs); + setPreview(result); + } catch { + // Error toast already shown by the mutation's onError. + } + }; + + const handleExecute = async () => { + if (!preview) return; + const inputs = buildAllocationInputs(allocations); + try { + const receipt = await executeMutation.mutateAsync({ + allocations: inputs, + preview, + }); + + const summary = receipt.swaps + .filter(swap => swap.deltaQuantity !== 0) + .map(swap => `${swap.keyTitle ?? swap.keyId}: ${formatSwapDelta(swap.deltaQuantity)}`) + .join(', '); + + showToast.transactionSuccess( + 'RebalanceExecuted', + summary || 'Portfolio rebalanced', + receipt.txHash + ); + setPreview(null); + } catch { + // Error toast already shown by the mutation's onError. + } + }; + + if (positions.length === 0) { + return ( +

+ You need to hold at least one creator key before you can configure + a rebalance. +

+ ); + } + + return ( +
+
+ {allocations.map(row => { + const creator = creators.find(c => c.id === row.keyId); + const position = positions.find(p => p.creatorId === row.keyId); + return ( +
+
+

+ {creator?.title ?? 'Creator key'} +

+

+ {position?.quantity ?? 0} keys held +

+
+
+ + handlePercentChange(row.keyId, e.target.value) + } + aria-label={`Target allocation for ${creator?.title ?? row.keyId}`} + data-testid={`rebalance-input-${row.keyId}`} + className="w-20 rounded-lg border border-white/10 bg-white/[0.04] px-2 py-1 text-right text-sm font-mono text-white outline-none focus:border-amber-400/50" + /> + % +
+
+ ); + })} + +
+ Total allocation + {formatAllocationSum(totalPercent)} +
+ {!isValidTotal && ( +

+ Allocations must sum to 100% before you can preview or + execute a rebalance. +

+ )} +
+ +
+ + +
+ + {previewMutation.isPending && ( +
+ +
+ )} + + {preview && ( +
+

+ Required swaps +

+ {preview.swaps.length === 0 ? ( +

+ Your portfolio already matches this allocation. +

+ ) : ( +
    + {preview.swaps.map(swap => ( +
  • + + {swap.keyTitle ?? swap.keyId} + + + {formatSwapDelta(swap.deltaQuantity)} + +
  • + ))} +
+ )} +
+ Estimated cost + + {formatDisplayKeyPrice(preview.estimatedTotalCostStroops)} + +
+
+ )} +
+ ); +}; + +export default PortfolioRebalancePanel; diff --git a/src/components/common/ReputationBreakdownPanel.tsx b/src/components/common/ReputationBreakdownPanel.tsx new file mode 100644 index 00000000..7e9a5048 --- /dev/null +++ b/src/components/common/ReputationBreakdownPanel.tsx @@ -0,0 +1,163 @@ +import React, { useMemo } from 'react'; +import { + Radar, + RadarChart, + PolarGrid, + PolarAngleAxis, + PolarRadiusAxis, + ResponsiveContainer, + Tooltip as ChartTooltip, +} from 'recharts'; +import { Info, ShieldCheck } from 'lucide-react'; +import { Tooltip } from '@/components/ui/tooltip'; +import Skeleton from '@/components/ui/skeleton'; +import { useKeyReputation } from '@/hooks/useKeyReputation'; +import { + REPUTATION_METHODOLOGY_TOOLTIP, + REPUTATION_SCORE_MAX, + buildReputationRadarData, + formatReputationScore, + reputationTier, +} from '@/utils/keyReputation.utils'; +import { cn } from '@/lib/utils'; + +export interface ReputationBreakdownPanelProps { + keyId: string; + className?: string; +} + +const TIER_TEXT_CLASSNAMES: Record = { + excellent: 'text-emerald-300', + good: 'text-amber-300', + fair: 'text-orange-300', + poor: 'text-rose-300', +}; + +/** + * Expanded reputation breakdown for the key detail page (#1143): the + * composite score, a radar chart of the four underlying metrics, and the + * raw per-metric values. Score is fetched from `GET /keys/:key_id/reputation`. + */ +export const ReputationBreakdownPanel: React.FC< + ReputationBreakdownPanelProps +> = ({ keyId, className }) => { + const { data, isLoading, isError } = useKeyReputation(keyId); + + const radarData = useMemo(() => buildReputationRadarData(data), [data]); + + if (isLoading) { + return ( +
+ + +
+ ); + } + + if (isError || !data) { + return ( +

+ Couldn't load the reputation score. Please try again later. +

+ ); + } + + const tier = reputationTier(data.score); + + return ( +
+
+
+
+ +
+

+ Reputation Score +

+ + + +
+
+ {formatReputationScore(data.score)} + + / {REPUTATION_SCORE_MAX} + +
+
+ +
+ + + + + + [ + `${entry.payload.value} / ${REPUTATION_SCORE_MAX}`, + entry.payload.metric, + ]} + /> + + + +
+ +
+ {radarData.map(point => ( +
+
{point.metric}
+
+ {point.value} / {REPUTATION_SCORE_MAX} +
+
+ ))} +
+
+ ); +}; + +export default ReputationBreakdownPanel; diff --git a/src/components/common/ReputationScoreBadge.tsx b/src/components/common/ReputationScoreBadge.tsx new file mode 100644 index 00000000..002acd50 --- /dev/null +++ b/src/components/common/ReputationScoreBadge.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { ShieldCheck } from 'lucide-react'; +import { Tooltip } from '@/components/ui/tooltip'; +import { useKeyReputation } from '@/hooks/useKeyReputation'; +import { + REPUTATION_METHODOLOGY_TOOLTIP, + REPUTATION_SCORE_MAX, + formatReputationScore, + reputationTier, +} from '@/utils/keyReputation.utils'; +import { cn } from '@/lib/utils'; + +export interface ReputationScoreBadgeProps { + keyId: string; + className?: string; +} + +const TIER_CLASSNAMES: Record = { + excellent: 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300', + good: 'border-amber-400/30 bg-amber-400/10 text-amber-300', + fair: 'border-orange-400/30 bg-orange-400/10 text-orange-300', + poor: 'border-rose-400/30 bg-rose-400/10 text-rose-300', +}; + +/** + * Reputation score badge shown on the discovery feed's key cards (#1143). + * Displays the composite score out of 1000 with a tooltip explaining the + * methodology. Renders nothing while loading or when no score is available, + * so it never disrupts card layout for keys without a computed score yet. + */ +export const ReputationScoreBadge: React.FC = ({ + keyId, + className, +}) => { + const { data, isLoading, isError } = useKeyReputation(keyId); + + if (isLoading || isError || !data) return null; + + const tier = reputationTier(data.score); + + return ( + + + + + ); +}; + +export default ReputationScoreBadge; diff --git a/src/hooks/useKeyReputation.ts b/src/hooks/useKeyReputation.ts new file mode 100644 index 00000000..059b7f86 --- /dev/null +++ b/src/hooks/useKeyReputation.ts @@ -0,0 +1,19 @@ +import { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { + keyReputationService, + type KeyReputation, +} from '@/services/keyReputation.service'; + +/** Reputation is recomputed daily server-side, so cache for 5 minutes (#1143). */ +export const KEY_REPUTATION_STALE_TIME_MS = 5 * 60 * 1000; + +export function useKeyReputation(keyId: string | undefined) { + return useQuery({ + queryKey: queryKeys.creators.reputation(keyId ?? ''), + queryFn: () => keyReputationService.getKeyReputation(keyId!), + enabled: Boolean(keyId), + staleTime: KEY_REPUTATION_STALE_TIME_MS, + retry: false, + }); +} diff --git a/src/hooks/useKeySnapshotHistory.ts b/src/hooks/useKeySnapshotHistory.ts new file mode 100644 index 00000000..041b0035 --- /dev/null +++ b/src/hooks/useKeySnapshotHistory.ts @@ -0,0 +1,24 @@ +import { useQuery } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { + keySnapshotHistoryService, + type KeySnapshot, + type SnapshotHistoryRange, +} from '@/services/keySnapshotHistory.service'; + +/** Daily snapshots change at most once a day, so cache for 5 minutes (#1142). */ +export const SNAPSHOT_HISTORY_STALE_TIME_MS = 5 * 60 * 1000; + +export function useKeySnapshotHistory( + keyId: string | undefined, + range: SnapshotHistoryRange +) { + return useQuery({ + queryKey: queryKeys.creators.snapshotHistory(keyId ?? '', range), + queryFn: () => + keySnapshotHistoryService.getSnapshotHistory(keyId!, range), + enabled: Boolean(keyId), + staleTime: SNAPSHOT_HISTORY_STALE_TIME_MS, + retry: false, + }); +} diff --git a/src/hooks/useMilestoneRewards.ts b/src/hooks/useMilestoneRewards.ts new file mode 100644 index 00000000..4a990ea3 --- /dev/null +++ b/src/hooks/useMilestoneRewards.ts @@ -0,0 +1,102 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { + milestoneRewardsService, + type KeyMilestoneRewards, +} from '@/services/milestoneRewards.service'; +import { submitContractCall } from '@/hooks/useKeyBuyback'; +import showToast from '@/utils/toast.util'; +import { getSignatureErrorMessage } from '@/utils/errorHandling.utils'; + +/** Milestones only change daily at most, so cache for 5 minutes (#1141). */ +export const MILESTONE_REWARDS_STALE_TIME_MS = 5 * 60 * 1000; + +export function useMilestoneRewards(wallet: string | undefined) { + return useQuery({ + queryKey: queryKeys.milestoneRewards.byWallet(wallet ?? ''), + queryFn: () => milestoneRewardsService.getMilestoneRewards(wallet!), + enabled: Boolean(wallet), + staleTime: MILESTONE_REWARDS_STALE_TIME_MS, + retry: false, + }); +} + +export interface ClaimMilestoneRewardVariables { + keyId: string; + milestoneIndex: number; +} + +export interface ClaimMilestoneRewardReceipt { + txHash: string; + keyId: string; + milestoneIndex: number; + receivedAmountStroops: number; + claimedAt: string; +} + +/** + * Submits the `claim_milestone_reward` contract call for a specific held + * key's milestone, then updates the cached milestone list optimistically on + * success so the card flips to "claimed" without an extra round trip (#1141). + */ +export function useClaimMilestoneReward(wallet: string | undefined) { + const queryClient = useQueryClient(); + + return useMutation< + ClaimMilestoneRewardReceipt, + Error, + ClaimMilestoneRewardVariables + >({ + mutationKey: ['contract', 'claim_milestone_reward', wallet], + mutationFn: async ({ keyId, milestoneIndex }) => { + const res = await submitContractCall('claim_milestone_reward', { + keyId, + milestoneIndex, + wallet, + }); + + // The mock payout scales with the milestone tier; a real backend + // would return the authoritative amount in the transaction receipt. + const receivedAmountStroops = (milestoneIndex + 1) * 10_000_000; + + return { + txHash: res.txHash, + keyId, + milestoneIndex, + receivedAmountStroops, + claimedAt: new Date().toISOString(), + }; + }, + onSuccess: receipt => { + if (!wallet) return; + + queryClient.setQueryData( + queryKeys.milestoneRewards.byWallet(wallet), + (old = []) => + old.map(key => + key.keyId !== receipt.keyId + ? key + : { + ...key, + milestones: key.milestones.map(milestone => + milestone.milestoneIndex !== receipt.milestoneIndex + ? milestone + : { + ...milestone, + claimed: true, + receivedAmountStroops: + receipt.receivedAmountStroops, + claimedAt: receipt.claimedAt, + } + ), + } + ) + ); + + showToast.success('Milestone reward claimed'); + }, + onError: error => { + showToast.error(getSignatureErrorMessage(error)); + }, + }); +} diff --git a/src/hooks/useRebalance.ts b/src/hooks/useRebalance.ts new file mode 100644 index 00000000..476ac429 --- /dev/null +++ b/src/hooks/useRebalance.ts @@ -0,0 +1,76 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { queryKeys } from '@/lib/queryKeys'; +import { + rebalanceService, + type RebalanceAllocationInput, + type RebalancePreview, +} from '@/services/rebalance.service'; +import { submitContractCall } from '@/hooks/useKeyBuyback'; +import showToast from '@/utils/toast.util'; +import { getSignatureErrorMessage } from '@/utils/errorHandling.utils'; + +/** + * Requests a swap preview for a set of target allocations via + * `get_rebalance_preview` (#1139). A mutation (not a query) because the + * preview depends on the allocations the user just typed, not a cacheable + * key. + */ +export function useRebalancePreviewMutation(wallet: string | undefined) { + return useMutation({ + mutationKey: ['rebalance', 'preview', wallet], + mutationFn: allocations => { + if (!wallet) throw new Error('Wallet is required'); + return rebalanceService.getRebalancePreview(wallet, allocations); + }, + onError: error => { + showToast.error(getSignatureErrorMessage(error)); + }, + }); +} + +export interface ExecuteRebalanceReceipt { + txHash: string; + allocations: RebalanceAllocationInput[]; + swaps: RebalancePreview['swaps']; + executedAt: string; +} + +/** + * Submits the `execute_rebalance` contract call with the configured target + * allocations, then invalidates the wallet's holdings so the portfolio + * reflects the new balances (#1139). + */ +export function useExecuteRebalanceMutation(wallet: string | undefined) { + const queryClient = useQueryClient(); + + return useMutation< + ExecuteRebalanceReceipt, + Error, + { allocations: RebalanceAllocationInput[]; preview: RebalancePreview } + >({ + mutationKey: ['contract', 'execute_rebalance', wallet], + mutationFn: async ({ allocations, preview }) => { + const res = await submitContractCall('execute_rebalance', { + wallet, + allocations, + }); + + return { + txHash: res.txHash, + allocations, + swaps: preview.swaps, + executedAt: new Date().toISOString(), + }; + }, + onSuccess: () => { + if (wallet) { + queryClient.invalidateQueries({ + queryKey: queryKeys.wallet.holdings(wallet), + }); + } + }, + onError: error => { + showToast.error(getSignatureErrorMessage(error)); + }, + }); +} diff --git a/src/lib/queryKeys.ts b/src/lib/queryKeys.ts index cd856dee..8a00e7e7 100644 --- a/src/lib/queryKeys.ts +++ b/src/lib/queryKeys.ts @@ -56,6 +56,10 @@ export const queryKeys = { ['creators', creatorId, 'oracle-price'] as const, performanceBond: (creatorId: string) => ['creators', creatorId, 'performance-bond'] as const, + snapshotHistory: (creatorId: string, range: string) => + ['creators', creatorId, 'snapshot-history', range] as const, + reputation: (creatorId: string) => + ['creators', creatorId, 'reputation'] as const, // Optional wallet scopes the cache per connected wallet (#915); omit it // to address every wallet's entry for the key (e.g. invalidation). tradeCooldown: (creatorId: string, wallet?: string) => @@ -203,6 +207,13 @@ export const queryKeys = { stakes: (wallet: string, keyId: string) => ['staking-vault', 'stakes', wallet, keyId] as const, }, + milestoneRewards: { + byWallet: (wallet: string) => + ['milestone-rewards', wallet] as const, + }, + rebalance: { + preview: (wallet: string) => ['rebalance', wallet, 'preview'] as const, + }, autoInvest: { all: () => ['auto-invest'] as const, rules: () => ['auto-invest', 'rules'] as const, diff --git a/src/pages/CreatorDetailPage.tsx b/src/pages/CreatorDetailPage.tsx index fa9bfe9a..ff1f99a4 100644 --- a/src/pages/CreatorDetailPage.tsx +++ b/src/pages/CreatorDetailPage.tsx @@ -18,6 +18,8 @@ import BuyCooldownCountdown from '@/components/common/BuyCooldownCountdown'; import KeyHolderList from '@/components/common/KeyHolderList'; import HolderConcentrationChart from '@/components/common/HolderConcentrationChart'; import HolderSnapshotHistory from '@/components/common/HolderSnapshotHistory'; +import KeyAnalyticsHistoryChart from '@/components/common/KeyAnalyticsHistoryChart'; +import ReputationBreakdownPanel from '@/components/common/ReputationBreakdownPanel'; import GraduationCelebration from '@/components/common/GraduationCelebration'; import StakingRewardsSection from '@/components/common/StakingRewardsSection'; import StakingVaultPanel from '@/components/common/StakingVaultPanel'; @@ -849,6 +851,32 @@ function CreatorDetailPageContent() { /> )} +
+

+ Analytics History +

+ + + + +
+ +
+

+ Reputation +

+ + + + +
+
}, + { label: 'Rebalance', value: 'rebalance', icon: }, + { label: 'Milestones', value: 'milestones', icon: }, { label: 'Vesting', value: 'vesting', icon: }, { label: 'Staking', value: 'staking', icon: }, { label: 'Liquidity', value: 'liquidity', icon: }, @@ -347,6 +353,56 @@ export default function ProfilePage() { )} + {/* Portfolio rebalance panel (#1139) */} + {activeTab === 'rebalance' && ( +
+
+

+ Rebalance Portfolio +

+

+ Configure target allocations across your held keys and + preview the swaps needed before executing a rebalance. +

+
+ + + +
+ )} + + {/* Milestone rewards panel (#1141) */} + {activeTab === 'milestones' && ( +
+
+

+ Milestone Rewards +

+

+ Loyalty rewards for continuously holding a creator key + past 30, 90, 180, and 365 days. +

+
+ + + +
+ )} + {/* Vesting schedules panel (#1018) */} {activeTab === 'vesting' && (
{ + try { + const response = await this.api.get>( + `/keys/${keyId}/reputation` + ); + return response.data.data; + } catch (error) { + throw this.handleError(error); + } + } +} + +export const keyReputationService = new KeyReputationService(); diff --git a/src/services/keySnapshotHistory.service.ts b/src/services/keySnapshotHistory.service.ts new file mode 100644 index 00000000..e4ea1c0b --- /dev/null +++ b/src/services/keySnapshotHistory.service.ts @@ -0,0 +1,50 @@ +import { BaseApiService, type APIResponse } from './api.service'; + +/** Date ranges offered by the key analytics chart (#1142). */ +export type SnapshotHistoryRange = '7d' | '30d' | '90d' | 'all'; + +/** Metrics the chart can plot, one per tab. */ +export type SnapshotMetric = 'price' | 'volume' | 'holders' | 'marketCap'; + +/** One daily on-chain snapshot for a key. */ +export interface KeySnapshot { + /** ISO date (or timestamp) the snapshot was taken. */ + snapshotDate: string; + /** Key price in stroops at snapshot time. */ + priceStroops: number; + /** Trading volume in stroops over the snapshot's day. */ + volumeStroops: number; + /** Total distinct holders at snapshot time. */ + holderCount: number; + /** Market cap in stroops (price * circulating supply) at snapshot time. */ + marketCapStroops: number; +} + +interface KeySnapshotsEnvelope { + snapshots: KeySnapshot[]; +} + +class KeySnapshotHistoryService extends BaseApiService { + /** + * Fetches daily on-chain snapshots for a key from + * `GET /keys/:key_id/snapshots?range=X` (#1142). + */ + async getSnapshotHistory( + keyId: string, + range: SnapshotHistoryRange + ): Promise { + try { + const response = await this.api.get< + APIResponse + >(`/keys/${keyId}/snapshots`, { params: { range } }); + + const payload = response.data.data; + if (Array.isArray(payload)) return payload; + return payload?.snapshots ?? []; + } catch (error) { + throw this.handleError(error); + } + } +} + +export const keySnapshotHistoryService = new KeySnapshotHistoryService(); diff --git a/src/services/milestoneRewards.service.ts b/src/services/milestoneRewards.service.ts new file mode 100644 index 00000000..ead08663 --- /dev/null +++ b/src/services/milestoneRewards.service.ts @@ -0,0 +1,54 @@ +import { BaseApiService, type APIResponse } from './api.service'; + +/** Holding-duration thresholds, in days, that unlock a milestone reward. */ +export const MILESTONE_THRESHOLDS_DAYS = [30, 90, 180, 365] as const; +export type MilestoneThresholdDays = (typeof MILESTONE_THRESHOLDS_DAYS)[number]; + +/** One loyalty milestone for a held key. */ +export interface MilestoneReward { + /** Index of this milestone within the key's milestone schedule (0-based). */ + milestoneIndex: number; + /** Holding-duration threshold in days, e.g. 30, 90, 180, 365. */ + thresholdDays: MilestoneThresholdDays; + /** Whether the reward has already been claimed. */ + claimed: boolean; + /** Amount received, in stroops, once claimed (undefined until then). */ + receivedAmountStroops?: number; + /** ISO timestamp the reward was claimed, when applicable. */ + claimedAt?: string; +} + +/** Milestone schedule for a single held key. */ +export interface KeyMilestoneRewards { + keyId: string; + keyTitle?: string; + /** Days this wallet has continuously held the key. */ + daysHeld: number; + milestones: MilestoneReward[]; +} + +interface MilestoneRewardsEnvelope { + keys: KeyMilestoneRewards[]; +} + +class MilestoneRewardsService extends BaseApiService { + /** + * Fetches every held key's milestone reward schedule for a wallet from + * `GET /wallets/:wallet/milestone-rewards` (#1141). + */ + async getMilestoneRewards(wallet: string): Promise { + try { + const response = await this.api.get< + APIResponse + >(`/wallets/${wallet}/milestone-rewards`); + + const payload = response.data.data; + if (Array.isArray(payload)) return payload; + return payload?.keys ?? []; + } catch (error) { + throw this.handleError(error); + } + } +} + +export const milestoneRewardsService = new MilestoneRewardsService(); diff --git a/src/services/rebalance.service.ts b/src/services/rebalance.service.ts new file mode 100644 index 00000000..88c9feca --- /dev/null +++ b/src/services/rebalance.service.ts @@ -0,0 +1,48 @@ +import { BaseApiService, type APIResponse } from './api.service'; + +/** A target allocation percentage configured for one held key. */ +export interface RebalanceAllocationInput { + keyId: string; + /** Target share of the portfolio, in percent (0-100). */ + targetPercent: number; +} + +/** A single swap required to move from the current to target allocation. */ +export interface RebalanceSwap { + keyId: string; + keyTitle?: string; + /** Positive to buy more of this key, negative to sell. */ + deltaQuantity: number; + /** Estimated stroops cost (positive) or proceeds (negative) of the swap. */ + estimatedCostStroops: number; +} + +export interface RebalancePreview { + swaps: RebalanceSwap[]; + /** Sum of all positive estimated costs, in stroops. */ + estimatedTotalCostStroops: number; +} + +class RebalanceService extends BaseApiService { + /** + * Previews the swaps required to move a wallet's portfolio to the given + * target allocations, via `POST /wallets/:wallet/rebalance/preview` + * (#1139). Does not execute anything on-chain. + */ + async getRebalancePreview( + wallet: string, + allocations: RebalanceAllocationInput[] + ): Promise { + try { + const response = await this.api.post>( + `/wallets/${wallet}/rebalance/preview`, + { allocations } + ); + return response.data.data; + } catch (error) { + throw this.handleError(error); + } + } +} + +export const rebalanceService = new RebalanceService(); diff --git a/src/utils/keyReputation.utils.ts b/src/utils/keyReputation.utils.ts new file mode 100644 index 00000000..2d0de74b --- /dev/null +++ b/src/utils/keyReputation.utils.ts @@ -0,0 +1,63 @@ +import type { KeyReputation } from '@/services/keyReputation.service'; + +/** Maximum value any reputation metric (composite or component) can hold. */ +export const REPUTATION_SCORE_MAX = 1000; + +/** Plain-language explanation of how the composite score is computed. */ +export const REPUTATION_METHODOLOGY_TOOLTIP = + 'Composite score out of 1000, weighted equally across trade volume, ' + + 'holder retention, price stability, and community rating. Recomputed ' + + 'daily from on-chain activity.'; + +export interface ReputationRadarPoint { + metric: string; + /** Raw score, 0–1000. */ + value: number; + /** Score rescaled to 0–100 for consistent radar axis display. */ + normalized: number; +} + +/** Clamps a raw score into the valid [0, REPUTATION_SCORE_MAX] range. */ +export function clampReputationScore(value: number | null | undefined): number { + if (value == null || !Number.isFinite(value)) return 0; + return Math.min(REPUTATION_SCORE_MAX, Math.max(0, value)); +} + +/** Builds the four-metric radar chart dataset from a reputation payload. */ +export function buildReputationRadarData( + reputation: KeyReputation | undefined +): ReputationRadarPoint[] { + if (!reputation) return []; + + const metrics: Array<[string, number]> = [ + ['Trade Volume', reputation.tradeVolumeScore], + ['Holder Retention', reputation.holderRetentionScore], + ['Price Stability', reputation.priceStabilityScore], + ['Rating', reputation.ratingScore], + ]; + + return metrics.map(([metric, rawValue]) => { + const value = clampReputationScore(rawValue); + return { + metric, + value, + normalized: Math.round((value / REPUTATION_SCORE_MAX) * 100), + }; + }); +} + +/** Qualitative tier label for a composite score, used for badge styling. */ +export type ReputationTier = 'excellent' | 'good' | 'fair' | 'poor'; + +export function reputationTier(score: number): ReputationTier { + const clamped = clampReputationScore(score); + if (clamped >= 800) return 'excellent'; + if (clamped >= 600) return 'good'; + if (clamped >= 400) return 'fair'; + return 'poor'; +} + +export function formatReputationScore(score: number | null | undefined): string { + if (score == null || !Number.isFinite(score)) return '—'; + return `${Math.round(clampReputationScore(score))}`; +} diff --git a/src/utils/keySnapshotHistory.utils.ts b/src/utils/keySnapshotHistory.utils.ts new file mode 100644 index 00000000..0fd7cb96 --- /dev/null +++ b/src/utils/keySnapshotHistory.utils.ts @@ -0,0 +1,113 @@ +import type { + KeySnapshot, + SnapshotHistoryRange, + SnapshotMetric, +} from '@/services/keySnapshotHistory.service'; + +export const SNAPSHOT_HISTORY_RANGES: ReadonlyArray<{ + value: SnapshotHistoryRange; + label: string; +}> = [ + { value: '7d', label: '7d' }, + { value: '30d', label: '30d' }, + { value: '90d', label: '90d' }, + { value: 'all', label: 'All Time' }, +]; + +export const SNAPSHOT_METRICS: ReadonlyArray<{ + value: SnapshotMetric; + label: string; +}> = [ + { value: 'price', label: 'Price' }, + { value: 'volume', label: 'Volume' }, + { value: 'holders', label: 'Holders' }, + { value: 'marketCap', label: 'Market Cap' }, +]; + +const DAY_MS = 24 * 60 * 60 * 1000; + +/** One chart-ready point: numeric timestamp plus every metric's value. */ +export interface SnapshotHistoryPoint { + ts: number; + price: number; + volume: number; + holders: number; + marketCap: number; +} + +function rangeWindowMs(range: SnapshotHistoryRange): number | null { + if (range === '7d') return 7 * DAY_MS; + if (range === '30d') return 30 * DAY_MS; + if (range === '90d') return 90 * DAY_MS; + return null; +} + +/** + * Converts raw snapshots into chart points for the selected range: drops + * malformed entries, keeps only points inside the window, and sorts + * oldest-to-newest so the line always reads left-to-right (#1142). + */ +export function buildSnapshotHistoryPoints( + snapshots: KeySnapshot[] | undefined, + range: SnapshotHistoryRange, + now: number = Date.now() +): SnapshotHistoryPoint[] { + const windowMs = rangeWindowMs(range); + const cutoff = windowMs == null ? -Infinity : now - windowMs; + + return (snapshots ?? []) + .map(snapshot => ({ + ts: Date.parse(snapshot.snapshotDate), + price: snapshot.priceStroops, + volume: snapshot.volumeStroops, + holders: snapshot.holderCount, + marketCap: snapshot.marketCapStroops, + })) + .filter(point => Number.isFinite(point.ts) && point.ts >= cutoff) + .sort((a, b) => a.ts - b.ts); +} + +/** Maps a metric to the chart point's corresponding dataKey. */ +export function metricDataKey( + metric: SnapshotMetric +): keyof Omit { + return metric; +} + +/** + * Value formatter for a metric's axis/tooltip display. Price and market cap + * are quoted in XLM (stroops / 10,000,000); volume likewise; holders is a + * plain count. + */ +export function formatMetricValue( + metric: SnapshotMetric, + value: number +): string { + if (!Number.isFinite(value)) return '—'; + if (metric === 'holders') { + return new Intl.NumberFormat('en-US').format(Math.round(value)); + } + const xlm = value / 10_000_000; + return `${new Intl.NumberFormat('en-US', { + maximumFractionDigits: 2, + }).format(xlm)} XLM`; +} + +export function formatSnapshotAxisDate(ts: number): string { + return new Date(ts).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }); +} + +export function metricLabel(metric: SnapshotMetric): string { + return SNAPSHOT_METRICS.find(m => m.value === metric)?.label ?? metric; +} + +/** True once there are enough points for the line to be meaningful. */ +export function hasSnapshotHistoryData( + points: SnapshotHistoryPoint[] +): boolean { + return points.length > 0; +} diff --git a/src/utils/milestoneRewards.utils.ts b/src/utils/milestoneRewards.utils.ts new file mode 100644 index 00000000..1cef3df7 --- /dev/null +++ b/src/utils/milestoneRewards.utils.ts @@ -0,0 +1,71 @@ +import type { + KeyMilestoneRewards, + MilestoneReward, +} from '@/services/milestoneRewards.service'; + +/** Visual/interaction state of a single milestone card (#1141). */ +export type MilestoneState = 'claimed' | 'claimable' | 'locked'; + +/** + * Derives a milestone's display state from its claimed flag and the key's + * current holding duration: claimed once the user has already claimed it, + * claimable once the threshold has been reached but not yet claimed, and + * locked otherwise. + */ +export function resolveMilestoneState( + milestone: MilestoneReward, + daysHeld: number +): MilestoneState { + if (milestone.claimed) return 'claimed'; + if (daysHeld >= milestone.thresholdDays) return 'claimable'; + return 'locked'; +} + +/** + * Progress toward a milestone's threshold, 0–100. Claimed/overdue milestones + * report 100. + */ +export function milestoneProgressPercent( + milestone: MilestoneReward, + daysHeld: number +): number { + if (milestone.claimed || daysHeld >= milestone.thresholdDays) return 100; + if (milestone.thresholdDays <= 0) return 100; + return Math.max( + 0, + Math.min(100, Math.round((daysHeld / milestone.thresholdDays) * 100)) + ); +} + +/** The next unclaimed milestone (claimable or locked), or null if none. */ +export function nextUnclaimedMilestone( + key: KeyMilestoneRewards +): MilestoneReward | null { + const unclaimed = key.milestones + .filter(m => !m.claimed) + .sort((a, b) => a.thresholdDays - b.thresholdDays); + return unclaimed[0] ?? null; +} + +/** Whether any held key has at least one milestone defined. */ +export function hasAnyMilestones(keys: KeyMilestoneRewards[]): boolean { + return keys.some(key => key.milestones.length > 0); +} + +/** Total count of milestones currently claimable across all held keys. */ +export function countClaimableMilestones( + keys: KeyMilestoneRewards[] +): number { + return keys.reduce( + (total, key) => + total + + key.milestones.filter( + m => resolveMilestoneState(m, key.daysHeld) === 'claimable' + ).length, + 0 + ); +} + +export function formatDaysHeld(days: number): string { + return `${Math.max(0, Math.floor(days))}d held`; +} diff --git a/src/utils/rebalance.utils.ts b/src/utils/rebalance.utils.ts new file mode 100644 index 00000000..28304142 --- /dev/null +++ b/src/utils/rebalance.utils.ts @@ -0,0 +1,52 @@ +import type { RebalanceAllocationInput } from '@/services/rebalance.service'; + +/** Allocation sums within this tolerance of 100 are treated as valid, + * absorbing floating point rounding from percentage inputs. */ +export const ALLOCATION_SUM_TOLERANCE = 0.01; + +export const TARGET_ALLOCATION_TOTAL = 100; + +/** Sums the configured target percentages across all allocation rows. */ +export function sumTargetAllocations( + allocations: Array<{ targetPercent: number }> +): number { + return allocations.reduce( + (total, allocation) => total + (Number(allocation.targetPercent) || 0), + 0 + ); +} + +/** + * Whether the configured allocations sum to exactly 100% (within floating + * point tolerance). Used to gate the Execute Rebalance button (#1139). + */ +export function isAllocationTotalValid( + allocations: Array<{ targetPercent: number }> +): boolean { + const sum = sumTargetAllocations(allocations); + return Math.abs(sum - TARGET_ALLOCATION_TOTAL) <= ALLOCATION_SUM_TOLERANCE; +} + +/** Rounds a running allocation sum for display, avoiding -0 artifacts. */ +export function formatAllocationSum(sum: number): string { + const rounded = Math.round(sum * 100) / 100; + return `${rounded === 0 ? 0 : rounded}%`; +} + +/** Builds the allocations payload the preview/execute API expects, dropping + * any rows with a non-finite or negative percent. */ +export function buildAllocationInputs( + rows: Array<{ keyId: string; targetPercent: number }> +): RebalanceAllocationInput[] { + return rows + .filter( + row => Number.isFinite(row.targetPercent) && row.targetPercent >= 0 + ) + .map(row => ({ keyId: row.keyId, targetPercent: row.targetPercent })); +} + +export function formatSwapDelta(deltaQuantity: number): string { + if (deltaQuantity === 0) return 'No change'; + const sign = deltaQuantity > 0 ? '+' : ''; + return `${sign}${deltaQuantity} key${Math.abs(deltaQuantity) === 1 ? '' : 's'}`; +}