harb/web-app/src/composables/usePositionDashboard.ts

289 lines
8 KiB
TypeScript

import { ref, computed, onMounted, onUnmounted, type Ref } from 'vue';
import axios from 'axios';
import { DEFAULT_CHAIN_ID } from '@/config';
import { resolveGraphqlEndpoint, formatGraphqlError } from '@/utils/graphqlRetry';
import logger from '@/utils/logger';
const GRAPHQL_TIMEOUT_MS = 15_000;
const POLL_INTERVAL_MS = 30_000;
export interface PositionRecord {
id: string;
owner: string;
share: number;
taxRate: number;
taxRateIndex: number;
kraikenDeposit: string;
stakeDeposit: string;
taxPaid: string;
snatched: number;
status: string;
creationTime: string;
lastTaxTime: string;
closedAt: string | null;
totalSupplyInit: string;
totalSupplyEnd: string | null;
payout: string | null;
}
export interface PositionStats {
stakeTotalSupply: string;
outstandingStake: string;
kraikenTotalSupply: string;
lastEthReserve: string;
}
export interface ActivePositionShort {
id: string;
taxRateIndex: number;
kraikenDeposit: string;
}
function formatTokenAmount(rawWei: string, decimals = 18): number {
try {
const big = BigInt(rawWei);
const divisor = 10 ** decimals;
return Number(big) / divisor;
} catch {
return 0;
}
}
function formatDate(ts: string | null): string {
if (!ts) return 'N/A';
try {
// ts may be seconds (unix) or ms
const num = Number(ts);
const ms = num > 1e12 ? num : num * 1000;
return new Date(ms).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return 'N/A';
}
}
function durationHuman(fromTs: string | null, toTs: string | null = null): string {
if (!fromTs) return 'N/A';
try {
const from = Number(fromTs) > 1e12 ? Number(fromTs) : Number(fromTs) * 1000;
const to = toTs ? (Number(toTs) > 1e12 ? Number(toTs) : Number(toTs) * 1000) : Date.now();
const diffMs = to - from;
if (diffMs < 0) return 'N/A';
const totalSec = Math.floor(diffMs / 1000);
const days = Math.floor(totalSec / 86400);
const hours = Math.floor((totalSec % 86400) / 3600);
const mins = Math.floor((totalSec % 3600) / 60);
const parts: string[] = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
if (mins > 0 && days === 0) parts.push(`${mins}m`);
return parts.length ? parts.join(' ') : '<1m';
} catch {
return 'N/A';
}
}
export function usePositionDashboard(positionId: Ref<string>) {
const position = ref<PositionRecord | null>(null);
const stats = ref<PositionStats | null>(null);
const allActivePositions = ref<ActivePositionShort[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
let pollTimer: ReturnType<typeof setInterval> | null = null;
async function fetchData() {
const id = positionId.value;
if (!id) return;
loading.value = true;
error.value = null;
let endpoint: string;
try {
endpoint = resolveGraphqlEndpoint(DEFAULT_CHAIN_ID);
} catch (err) {
error.value = err instanceof Error ? err.message : 'GraphQL endpoint not configured';
loading.value = false;
return;
}
try {
const res = await axios.post(
endpoint,
{
query: `query PositionDashboard {
positions(id: "${id}") {
id
owner
share
taxRate
taxRateIndex
kraikenDeposit
stakeDeposit
taxPaid
snatched
status
creationTime
lastTaxTime
closedAt
totalSupplyInit
totalSupplyEnd
payout
}
statss(where: { id: "0x01" }) {
items {
stakeTotalSupply
outstandingStake
kraikenTotalSupply
lastEthReserve
}
}
positionss(where: { status: "Active" }, limit: 1000) {
items {
id
taxRateIndex
kraikenDeposit
}
}
}`,
},
{ timeout: GRAPHQL_TIMEOUT_MS }
);
const gqlErrors = res.data?.errors;
if (Array.isArray(gqlErrors) && gqlErrors.length > 0) {
throw new Error(gqlErrors.map((e: { message?: string }) => e.message ?? 'GraphQL error').join(', '));
}
position.value = res.data?.data?.positions ?? null;
const statsItems = res.data?.data?.statss?.items;
stats.value = Array.isArray(statsItems) && statsItems.length > 0 ? (statsItems[0] as PositionStats) : null;
const activeItems = res.data?.data?.positionss?.items;
allActivePositions.value = Array.isArray(activeItems) ? (activeItems as ActivePositionShort[]) : [];
logger.info(`PositionDashboard loaded for #${id}`);
} catch (err) {
error.value = formatGraphqlError(err);
logger.info('PositionDashboard fetch error', err);
} finally {
loading.value = false;
}
}
// Derived values
const depositKrk = computed(() => formatTokenAmount(position.value?.kraikenDeposit ?? '0'));
const taxPaidKrk = computed(() => formatTokenAmount(position.value?.taxPaid ?? '0'));
const currentValueKrk = computed(() => {
if (!position.value || !stats.value) return 0;
const share = Number(position.value.share);
const outstanding = formatTokenAmount(stats.value.outstandingStake);
return share * outstanding;
});
const netReturnKrk = computed(() => {
return currentValueKrk.value - depositKrk.value - taxPaidKrk.value;
});
const taxRatePercent = computed(() => {
if (!position.value) return 0;
return Number(position.value.taxRate) * 100;
});
const dailyTaxCost = computed(() => {
if (!position.value) return 0;
return (depositKrk.value * Number(position.value.taxRate)) / 365;
});
const sharePercent = computed(() => {
if (!position.value) return 0;
return Number(position.value.share) * 100;
});
const createdFormatted = computed(() => formatDate(position.value?.creationTime ?? null));
const lastTaxFormatted = computed(() => formatDate(position.value?.lastTaxTime ?? null));
const closedAtFormatted = computed(() => formatDate(position.value?.closedAt ?? null));
const timeHeld = computed(() => {
if (!position.value) return 'N/A';
return durationHuman(position.value.creationTime, position.value.closedAt ?? null);
});
// Snatch risk: count active positions with lower taxRateIndex
const snatchRisk = computed(() => {
if (!position.value) return { count: 0, level: 'UNKNOWN', color: '#9A9898' };
const myIndex = Number(position.value.taxRateIndex);
const lower = allActivePositions.value.filter(p => Number(p.taxRateIndex) < myIndex).length;
const total = allActivePositions.value.length;
let level: string;
let color: string;
if (total === 0) {
level = 'LOW';
color = '#4ADE80';
} else {
const ratio = lower / total;
if (ratio < 0.33) {
level = 'LOW';
color = '#4ADE80';
} else if (ratio < 0.67) {
level = 'MEDIUM';
color = '#FACC15';
} else {
level = 'HIGH';
color = '#F87171';
}
}
return { count: lower, level, color };
});
const payoutKrk = computed(() => formatTokenAmount(position.value?.payout ?? '0'));
const netPnlKrk = computed(() => {
if (!position.value) return 0;
return payoutKrk.value - depositKrk.value - taxPaidKrk.value;
});
onMounted(async () => {
await fetchData();
pollTimer = setInterval(() => void fetchData(), POLL_INTERVAL_MS);
});
onUnmounted(() => {
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
});
return {
loading,
error,
position,
stats,
allActivePositions,
depositKrk,
taxPaidKrk,
currentValueKrk,
netReturnKrk,
taxRatePercent,
dailyTaxCost,
sharePercent,
createdFormatted,
lastTaxFormatted,
closedAtFormatted,
timeHeld,
snatchRisk,
payoutKrk,
netPnlKrk,
refresh: fetchData,
};
}