fix: address review findings for snatch notifications (#151)
- Extract relativeTime and formatTokenAmount to helper.ts, eliminating duplicated logic between CollapseHistory and NotificationBell - Use formatUnits (via formatTokenAmount) instead of Number(BigInt)/1e18 to avoid precision loss on large token amounts - Fix allRecentSnatches emptying after mark-seen: now runs two parallel queries — one filtered by lastSeen timestamp (unseen badge count) and one unfiltered (panel history), so history is preserved after opening - Remove dead no-op watch block from useSnatchNotifications Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
60d0859eb3
commit
2fffd2a567
4 changed files with 78 additions and 92 deletions
|
|
@ -18,7 +18,7 @@
|
||||||
<div class="notification-bell__event-details">
|
<div class="notification-bell__event-details">
|
||||||
<div class="notification-bell__event-title">Position snatched</div>
|
<div class="notification-bell__event-title">Position snatched</div>
|
||||||
<div class="notification-bell__event-meta">
|
<div class="notification-bell__event-meta">
|
||||||
<span>{{ formatKrk(event.tokenAmount) }} $KRK</span>
|
<span>{{ formatTokenAmount(event.tokenAmount, 18, 2) }} $KRK</span>
|
||||||
<span class="notification-bell__event-time">{{ relativeTime(event.timestamp) }}</span>
|
<span class="notification-bell__event-time">{{ relativeTime(event.timestamp) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -35,6 +35,7 @@ import { Icon } from '@iconify/vue';
|
||||||
import { useSnatchNotifications } from '@/composables/useSnatchNotifications';
|
import { useSnatchNotifications } from '@/composables/useSnatchNotifications';
|
||||||
import { useWallet } from '@/composables/useWallet';
|
import { useWallet } from '@/composables/useWallet';
|
||||||
import { DEFAULT_CHAIN_ID } from '@/config';
|
import { DEFAULT_CHAIN_ID } from '@/config';
|
||||||
|
import { relativeTime, formatTokenAmount } from '@/utils/helper';
|
||||||
|
|
||||||
const wallet = useWallet();
|
const wallet = useWallet();
|
||||||
const chainId = wallet.account.chainId ?? DEFAULT_CHAIN_ID;
|
const chainId = wallet.account.chainId ?? DEFAULT_CHAIN_ID;
|
||||||
|
|
@ -43,29 +44,6 @@ const { unseenCount, allRecentSnatches, isOpen, toggle, close } = useSnatchNotif
|
||||||
|
|
||||||
const bellRef = ref<HTMLElement | null>(null);
|
const bellRef = ref<HTMLElement | null>(null);
|
||||||
|
|
||||||
function formatKrk(raw: string): string {
|
|
||||||
try {
|
|
||||||
const val = Number(BigInt(raw)) / 1e18;
|
|
||||||
return val.toFixed(2);
|
|
||||||
} catch {
|
|
||||||
return '?';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function relativeTime(ts: string): string {
|
|
||||||
try {
|
|
||||||
const sec = Number(ts);
|
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
|
||||||
const diff = nowSec - sec;
|
|
||||||
if (diff < 60) return 'just now';
|
|
||||||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
|
||||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
|
||||||
return `${Math.floor(diff / 86400)}d ago`;
|
|
||||||
} catch {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
if (bellRef.value && !bellRef.value.contains(event.target as Node)) {
|
if (bellRef.value && !bellRef.value.contains(event.target as Node)) {
|
||||||
close();
|
close();
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ import type { Position } from '@/composables/usePositions';
|
||||||
import FCollapse from '@/components/fcomponents/FCollapse.vue';
|
import FCollapse from '@/components/fcomponents/FCollapse.vue';
|
||||||
import FTag from '@/components/fcomponents/FTag.vue';
|
import FTag from '@/components/fcomponents/FTag.vue';
|
||||||
import { RouterLink } from 'vue-router';
|
import { RouterLink } from 'vue-router';
|
||||||
import { compactNumber } from '@/utils/helper';
|
import { compactNumber, relativeTime, formatTokenAmount } from '@/utils/helper';
|
||||||
import { calculateClosedPositionProfit } from 'kraiken-lib/position';
|
import { calculateClosedPositionProfit } from 'kraiken-lib/position';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
|
@ -69,29 +69,12 @@ const profit = computed(() => {
|
||||||
|
|
||||||
const payoutKrk = computed(() => {
|
const payoutKrk = computed(() => {
|
||||||
if (!props.position.payout) return null;
|
if (!props.position.payout) return null;
|
||||||
try {
|
return formatTokenAmount(props.position.payout);
|
||||||
const raw = BigInt(props.position.payout);
|
|
||||||
// payout is stored in wei (18 decimals)
|
|
||||||
const krk = Number(raw) / 1e18;
|
|
||||||
return krk.toFixed(4);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const relativeClosedAt = computed(() => {
|
const relativeClosedAt = computed(() => {
|
||||||
if (!props.position.closedAt) return null;
|
if (!props.position.closedAt) return null;
|
||||||
try {
|
return relativeTime(props.position.closedAt) || null;
|
||||||
const ts = Number(props.position.closedAt);
|
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
|
||||||
const diffSec = nowSec - ts;
|
|
||||||
if (diffSec < 60) return 'just now';
|
|
||||||
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m ago`;
|
|
||||||
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h ago`;
|
|
||||||
return `${Math.floor(diffSec / 86400)}d ago`;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
import { ref, computed, onMounted, onUnmounted } from 'vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { getAccount, watchAccount } from '@wagmi/core';
|
import { getAccount, watchAccount } from '@wagmi/core';
|
||||||
import type { WatchAccountReturnType } from '@wagmi/core';
|
import type { WatchAccountReturnType } from '@wagmi/core';
|
||||||
|
|
@ -38,8 +38,45 @@ function setLastSeen(address: string, timestamp: string): void {
|
||||||
localStorage.setItem(storageKey(address), timestamp);
|
localStorage.setItem(storageKey(address), timestamp);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchUnseenSnatches(chainId: number, address: string): Promise<void> {
|
async function fetchSnatchEvents(endpoint: string, address: string, since: string | null): Promise<SnatchEvent[]> {
|
||||||
const since = getLastSeen(address);
|
const holder = address.toLowerCase();
|
||||||
|
const whereClause =
|
||||||
|
since !== null
|
||||||
|
? `{ holder: "${holder}", type: "snatch_out", timestamp_gt: "${since}" }`
|
||||||
|
: `{ holder: "${holder}", type: "snatch_out" }`;
|
||||||
|
const res = await axios.post(
|
||||||
|
endpoint,
|
||||||
|
{
|
||||||
|
query: `query SnatchEvents {
|
||||||
|
transactionss(
|
||||||
|
where: ${whereClause}
|
||||||
|
orderBy: "timestamp"
|
||||||
|
orderDirection: "desc"
|
||||||
|
limit: 20
|
||||||
|
) {
|
||||||
|
items { id timestamp tokenAmount ethAmount txHash }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
{ timeout: GRAPHQL_TIMEOUT_MS }
|
||||||
|
);
|
||||||
|
|
||||||
|
const errors = res.data?.errors;
|
||||||
|
if (Array.isArray(errors) && errors.length > 0) {
|
||||||
|
logger.info('useSnatchNotifications GraphQL errors', errors);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return (res.data?.data?.transactionss?.items ?? []) as SnatchEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh(chainId: number): Promise<void> {
|
||||||
|
const account = getAccount(config as Config);
|
||||||
|
if (!account.address) {
|
||||||
|
unseenSnatches.value = [];
|
||||||
|
allRecentSnatches.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let endpoint: string;
|
let endpoint: string;
|
||||||
try {
|
try {
|
||||||
endpoint = resolveGraphqlEndpoint(chainId);
|
endpoint = resolveGraphqlEndpoint(chainId);
|
||||||
|
|
@ -50,57 +87,28 @@ async function fetchUnseenSnatches(chainId: number, address: string): Promise<vo
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await axios.post(
|
const since = getLastSeen(account.address);
|
||||||
endpoint,
|
const [unseen, all] = await Promise.all([
|
||||||
{
|
fetchSnatchEvents(endpoint, account.address, since),
|
||||||
query: `query UnseenSnatches($holder: String!, $since: BigInt!) {
|
fetchSnatchEvents(endpoint, account.address, null),
|
||||||
transactionss(
|
]);
|
||||||
where: { holder: $holder, type: "snatch_out", timestamp_gt: $since }
|
unseenSnatches.value = unseen;
|
||||||
orderBy: "timestamp"
|
allRecentSnatches.value = all;
|
||||||
orderDirection: "desc"
|
|
||||||
limit: 20
|
|
||||||
) {
|
|
||||||
items { id timestamp tokenAmount ethAmount txHash }
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
variables: { holder: address.toLowerCase(), since },
|
|
||||||
},
|
|
||||||
{ timeout: GRAPHQL_TIMEOUT_MS }
|
|
||||||
);
|
|
||||||
|
|
||||||
const errors = res.data?.errors;
|
|
||||||
if (Array.isArray(errors) && errors.length > 0) {
|
|
||||||
logger.info('useSnatchNotifications GraphQL errors', errors);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const items: SnatchEvent[] = res.data?.data?.transactionss?.items ?? [];
|
|
||||||
unseenSnatches.value = items;
|
|
||||||
allRecentSnatches.value = items;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.info('useSnatchNotifications fetch failed', err);
|
logger.info('useSnatchNotifications fetch failed', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh(chainId: number): Promise<void> {
|
|
||||||
const account = getAccount(config as Config);
|
|
||||||
if (!account.address) {
|
|
||||||
unseenSnatches.value = [];
|
|
||||||
allRecentSnatches.value = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await fetchUnseenSnatches(chainId, account.address);
|
|
||||||
}
|
|
||||||
|
|
||||||
function markSeen(): void {
|
function markSeen(): void {
|
||||||
const account = getAccount(config as Config);
|
const account = getAccount(config as Config);
|
||||||
if (!account.address || allRecentSnatches.value.length === 0) return;
|
if (!account.address) return;
|
||||||
|
|
||||||
// Use the most recent timestamp from current results
|
// Advance last-seen to the most recent event timestamp
|
||||||
const latestTs = allRecentSnatches.value[0]?.timestamp;
|
const latestTs = allRecentSnatches.value[0]?.timestamp ?? unseenSnatches.value[0]?.timestamp;
|
||||||
if (latestTs) {
|
if (latestTs) {
|
||||||
setLastSeen(account.address, latestTs);
|
setLastSeen(account.address, latestTs);
|
||||||
}
|
}
|
||||||
|
// Clear badge; allRecentSnatches stays populated so the panel still shows history
|
||||||
unseenSnatches.value = [];
|
unseenSnatches.value = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,11 +154,6 @@ export function useSnatchNotifications(chainId: number = DEFAULT_CHAIN_ID) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Re-fetch when bell is closed to clear unseen after markSeen
|
|
||||||
watch(isOpen, async newVal => {
|
|
||||||
if (!newVal) return;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
unseenCount,
|
unseenCount,
|
||||||
unseenSnatches,
|
unseenSnatches,
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,28 @@ export function InsertCommaNumber(number: number) {
|
||||||
return formattedWithOptions;
|
return formattedWithOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function relativeTime(timestamp: string | number): string {
|
||||||
|
try {
|
||||||
|
const sec = typeof timestamp === 'string' ? Number(timestamp) : timestamp;
|
||||||
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
|
const diff = nowSec - sec;
|
||||||
|
if (diff < 60) return 'just now';
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
|
return `${Math.floor(diff / 86400)}d ago`;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTokenAmount(raw: string, decimals: number = 18, digits: number = 4): string {
|
||||||
|
try {
|
||||||
|
return Number(formatUnits(BigInt(raw), decimals)).toFixed(digits);
|
||||||
|
} catch {
|
||||||
|
return '?';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function formatBigIntDivision(nominator: bigint, denominator: bigint, _digits: number = 2) {
|
export function formatBigIntDivision(nominator: bigint, denominator: bigint, _digits: number = 2) {
|
||||||
if (!nominator) {
|
if (!nominator) {
|
||||||
return 0;
|
return 0;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue