fix(price): delay empty-quote UI until settle window

Avoid flashing no-quote too early while waiting for live/SSE price after first empty response.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-27 18:47:42 +03:30
co-authored by Cursor
parent 059ba0b565
commit 449fad5ed1
3 changed files with 100 additions and 8 deletions
+62 -2
View File
@@ -19,10 +19,19 @@ export const price = ref<Price>()
export const lastPrice = ref<Price>()
/** حداقل یک بار تلاش برای گرفتن مظنه تمام شده (موفق یا ناموفق) */
export const lastPriceReady = ref(false)
/**
* بعد از اولین پاسخ خالی/no_quote، چند لحظه صبر می‌کنیم تا تیک SSE/زنده برسد
* و «مظنه در دسترس نیست» زودهنگام نشان داده نشود.
*/
export const lastPriceSettleGeneration = ref(0)
export const olderPrice = ref<number | null>(null)
const QUOTE_ABSENCE_SETTLE_MS = 2500
let lastPriceSourceAt = 0
let consecutivePriceFetchFailures = 0
let lastPriceReadyAt = 0
let quoteSettleTimer: ReturnType<typeof setTimeout> | undefined
/** buy/sell نمایش و معامله — همان buy/sell/price_last از /api/price/last */
export function getTradeBuyPrice(p?: Price | null) {
@@ -50,6 +59,49 @@ function hasValidPricePayload(data?: Price | null): boolean {
)
}
function clearQuoteSettleTimer() {
if (quoteSettleTimer) {
clearTimeout(quoteSettleTimer)
quoteSettleTimer = undefined
}
}
function bumpQuoteSettle() {
lastPriceSettleGeneration.value += 1
}
function scheduleQuoteAbsenceSettle() {
clearQuoteSettleTimer()
if (hasValidPricePayload(lastPrice.value) || lastPrice.value?.status === 'closed') {
bumpQuoteSettle()
return
}
const elapsed = lastPriceReadyAt > 0 ? Date.now() - lastPriceReadyAt : 0
const wait = Math.max(0, QUOTE_ABSENCE_SETTLE_MS - elapsed)
quoteSettleTimer = setTimeout(() => {
quoteSettleTimer = undefined
bumpQuoteSettle()
}, wait)
}
function markLastPriceReady() {
if (!lastPriceReady.value) {
lastPriceReadyAt = Date.now()
}
lastPriceReady.value = true
scheduleQuoteAbsenceSettle()
}
/** قیمت آمده، بسته قطعی است، یا پنجرهٔ انتظارِ مظنه خالی تمام شده */
export function isLastPriceAbsenceSettled(): boolean {
void lastPriceSettleGeneration.value
if (!lastPriceReady.value) return false
if (hasValidPricePayload(lastPrice.value)) return true
if (lastPrice.value?.status === 'closed') return true
if (lastPriceReadyAt <= 0) return false
return Date.now() - lastPriceReadyAt >= QUOTE_ABSENCE_SETTLE_MS
}
function applyTradingHoursSettingsPatch(data: unknown) {
if (!data || typeof data !== 'object') {
return
@@ -183,7 +235,7 @@ export async function getLastPrices(): Promise<boolean> {
markPriceSourceFetchFailed()
return false
} finally {
lastPriceReady.value = true
markLastPriceReady()
}
}
@@ -192,13 +244,21 @@ export function applyLastPricePayload(dataLast: Price) {
const normalized = normalizePriceResponse(applyBoardSpecialToLastPayload(dataLast as any) as Price)
const nextBuy = getTradeBuyPrice(normalized)
const prevBuy = getTradeBuyPrice(lastPrice.value)
const nextValid = hasValidPricePayload(normalized)
const prevValid = hasValidPricePayload(lastPrice.value)
// قطع فید / no_quote خالی — قیمت فعلی را پاک نکن؛ فقط closed قطعی اجازهٔ صفر شدن دارد
if (!nextValid && prevValid && normalized.status !== 'closed') {
markLastPriceReady()
return
}
if (prevBuy > 0 && nextBuy > 0 && prevBuy !== nextBuy) {
olderPrice.value = prevBuy
}
lastPrice.value = normalized
lastPriceReady.value = true
markLastPriceReady()
price.value = {
...normalized,
buy: nextBuy,
+23 -2
View File
@@ -426,9 +426,14 @@ function handleLastPayload(payload: unknown) {
const raw = payload as Price & { settings_patch?: unknown }
const last = applyBoardSpecialToLastPayload(raw)
const hasQuote = last.price_last != null || last.price != null || last.buy != null
const hasDisplayable =
Number(last.buy ?? 0) > 0
|| Number(last.sell ?? 0) > 0
|| Number(last.price_last ?? 0) > 0
|| Number(last.price ?? 0) > 0
const hasStatus = last.status != null || typeof last.closed === 'boolean'
if (!hasQuote && !hasStatus) {
if (!hasDisplayable && !hasStatus) {
if (Array.isArray(raw.settings_patch) && raw.settings_patch.length) {
touchStreamActivity()
void applySettingsFromStream(raw.settings_patch)
@@ -436,6 +441,22 @@ function handleLastPayload(payload: unknown) {
return
}
// SSE گاهی last خالی/no_quote می‌فرستد؛ قیمت معتبر فعلی را پاک نکن
if (!hasDisplayable) {
if (Array.isArray(raw.settings_patch) && raw.settings_patch.length) {
touchStreamActivity()
void applySettingsFromStream(raw.settings_patch)
}
void import('@/utils/price').then(({ getTradeBuyPrice, lastPrice }) => {
const keepCurrent =
getTradeBuyPrice(lastPrice.value) > 0 && last.status !== 'closed'
touchStreamActivity()
if (keepCurrent) return
void applyLastFromStream(last)
})
return
}
touchStreamActivity()
void applyLastFromStream(last)
}
+15 -4
View File
@@ -1,6 +1,6 @@
import { ref } from 'vue'
import { useSettingsStore } from '@/store/settingsStore'
import { lastPrice, lastPriceReady } from '@/utils/price'
import { isLastPriceAbsenceSettled, lastPrice, lastPriceReady } from '@/utils/price'
export interface StoreTradingHours {
start: string
@@ -299,7 +299,10 @@ export function getPriceDisplayState(): PriceDisplayState {
const p = lastPrice.value
const serverStatus = p?.status
if (serverStatus === 'open' || serverStatus === 'closed' || serverStatus === 'no_quote') {
// اگر عدد قیمت هست، no_quote لحظه‌ای سرور را جدی نگیر (race با تیک بعدی)
if (serverStatus === 'no_quote' && hasShowableHomePrice(p)) {
// fall through به منطق کلاینت
} else if (serverStatus === 'open' || serverStatus === 'closed' || serverStatus === 'no_quote') {
return serverStatus
}
@@ -310,19 +313,27 @@ export function getPriceDisplayState(): PriceDisplayState {
if (hasLiveQuote(p)) return 'open'
if (shouldShowLastSavedPrice() && hasShowableHomePrice(p)) return 'open'
if (hasShowableHomePrice(p)) return 'open'
return 'no_quote'
}
/** اولین دریافت مظنه / تنظیمات هنوز تمام نشده — اسکلتون، نه «مظنه در دسترس نیست» */
/**
* اسکلتون تا وقتی قیمت قابل‌نمایش برسد، یا وضعیت بسته قطعی شود،
* یا بعد از اولین پاسخ خالی پنجرهٔ settle تمام شود (مظنه واقعاً نیست).
*/
export function isHomeQuotePending(): boolean {
const settings = useSettingsStore()
void lastPriceReady.value
void lastPrice.value
void isLastPriceAbsenceSettled()
void settings.loaded
void settings.settingsRevision
return !settings.loaded || !lastPriceReady.value
if (!settings.loaded || !lastPriceReady.value) return true
if (hasShowableHomePrice(lastPrice.value)) return false
if (lastPrice.value?.status === 'closed') return false
return !isLastPriceAbsenceSettled()
}
export function getPriceDisplayMessage(state: PriceDisplayState = getPriceDisplayState()): string {