fix(price): apply live trading-hours settings without refresh

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-25 02:29:14 +03:30
co-authored by Cursor
parent e87ba066db
commit ea71591557
4 changed files with 215 additions and 45 deletions
+15
View File
@@ -8,6 +8,7 @@ import {
shouldUsePricePolling,
} from '@/utils/priceSocketState'
import { applyBoardSpecialToLastPayload } from '@/utils/specialPricing'
import { useSettingsStore, type AppSetting } from '@/store/settingsStore'
const LAST_INTERVAL_MS = 10000
@@ -47,6 +48,19 @@ function hasValidPricePayload(data?: Price | null): boolean {
)
}
function applyTradingHoursSettingsPatch(data: unknown) {
if (!data || typeof data !== 'object') {
return
}
const patch = (data as { settings_patch?: unknown }).settings_patch
if (!Array.isArray(patch) || patch.length === 0) {
return
}
useSettingsStore().mergeSettings(patch as AppSetting[])
}
export function markPriceSourceReceived() {
lastPriceSourceAt = Date.now()
consecutivePriceFetchFailures = 0
@@ -170,6 +184,7 @@ export async function getLastPrices(): Promise<boolean> {
}
export function applyLastPricePayload(dataLast: Price) {
applyTradingHoursSettingsPatch(dataLast)
const normalized = normalizePriceResponse(applyBoardSpecialToLastPayload(dataLast as any) as Price)
const nextBuy = getTradeBuyPrice(normalized)
const prevBuy = getTradeBuyPrice(lastPrice.value)
+42 -2
View File
@@ -399,13 +399,40 @@ async function applyLastFromStream(last: Price) {
applyLastPricePayload(last)
}
async function applySettingsFromStream(payload: unknown) {
const items = Array.isArray(payload)
? payload
: payload && typeof payload === 'object' && Array.isArray((payload as { items?: unknown }).items)
? (payload as { items: unknown[] }).items
: null
if (!items?.length) {
return
}
try {
const { useSettingsStore } = await import('@/store/settingsStore')
const { touchStoreHoursSettings } = await import('@/utils/storeHours')
useSettingsStore().mergeSettings(items as Array<{ key?: string; value?: unknown }>)
touchStoreHoursSettings()
} catch (error) {
console.warn('Settings SSE apply failed', error)
}
}
function handleLastPayload(payload: unknown) {
if (!payload || typeof payload !== 'object') {
return
}
const last = applyBoardSpecialToLastPayload(payload as Price)
if (last.price_last == null && last.price == null && last.buy == null) {
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 hasStatus = last.status != null || typeof last.closed === 'boolean'
if (!hasQuote && !hasStatus) {
if (Array.isArray(raw.settings_patch) && raw.settings_patch.length) {
touchStreamActivity()
void applySettingsFromStream(raw.settings_patch)
}
return
}
@@ -413,6 +440,11 @@ function handleLastPayload(payload: unknown) {
void applyLastFromStream(last)
}
function handleSettingsPayload(payload: unknown) {
touchStreamActivity()
void applySettingsFromStream(payload)
}
function handleListPayload(payload: unknown) {
if (!Array.isArray(payload) || !payload.length) {
return
@@ -467,6 +499,14 @@ function bindStreamEvents(source: AuthPriceStream) {
}
})
source.addEventListener('settings', (event) => {
try {
handleSettingsPayload(JSON.parse(event.data))
} catch (error) {
console.warn('Price SSE settings parse failed', error)
}
})
source.addEventListener('list', (event) => {
try {
handleListPayload(JSON.parse(event.data))
+140 -40
View File
@@ -1,6 +1,6 @@
import { ref } from 'vue'
import { useSettingsStore } from '@/store/settingsStore'
import { getTradeBuyPrice, getTradeSellPrice, lastPrice } from '@/utils/price'
import { lastPrice } from '@/utils/price'
export interface StoreTradingHours {
start: string
@@ -12,8 +12,24 @@ export interface EffectiveTradingHours {
start: string
end: string
enforced: boolean
dayEnabled: boolean
}
type WeekdayKey = 'sat' | 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri'
type DaySchedule = {
enabled: boolean
start: string
end: string
}
type WeeklySchedule = {
mode: 'uniform' | 'per_day'
days: Record<WeekdayKey, DaySchedule>
}
const WEEKDAY_BY_JS: WeekdayKey[] = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat']
const productTradingHours = ref<{ start: string; end: string } | null>(null)
const productTradingHoursVersion = ref(0)
@@ -54,6 +70,11 @@ function hasProductTradingWindow(start?: string | null, end?: string | null): bo
return open !== close
}
/** بعد از merge زندهٔ تنظیمات ساعت کاری — computedهای صفحه اصلی دوباره حساب شوند */
export function touchStoreHoursSettings() {
productTradingHoursVersion.value += 1
}
export function setProductTradingHours(start?: string | null, end?: string | null) {
if (!hasProductTradingWindow(start, end)) {
productTradingHours.value = null
@@ -81,29 +102,77 @@ export function syncProductTradingHoursFromProducts(
setProductTradingHours(null, null)
}
export function getStoreTradingHours(): StoreTradingHours {
const settings = useSettingsStore()
const closeRaw = settings.getSettingByKey('close_price')
const closePriceEnabled =
closeRaw === true ||
closeRaw === 1 ||
String(closeRaw ?? 'false').trim().toLowerCase() === 'true'
function weekdayKey(now: Date): WeekdayKey {
return WEEKDAY_BY_JS[now.getDay()] ?? 'sat'
}
return {
start: normalizeTradingTimeSetting(settings.getSettingByKey('startDay'), '00:00'),
end: normalizeTradingTimeSetting(settings.getSettingByKey('endDay'), '23:59'),
closePriceEnabled,
function parseWeeklySchedule(raw: unknown): WeeklySchedule | null {
const text = String(raw ?? '').trim()
if (!text) return null
try {
const parsed = JSON.parse(text) as Partial<WeeklySchedule>
if (!parsed?.days || typeof parsed.days !== 'object') return null
const mode = parsed.mode === 'per_day' ? 'per_day' : 'uniform'
const defaults = getStoreTradingHours()
const keys: WeekdayKey[] = ['sat', 'sun', 'mon', 'tue', 'wed', 'thu', 'fri']
const days = {} as Record<WeekdayKey, DaySchedule>
for (const key of keys) {
const row = parsed.days[key]
days[key] = {
enabled: row?.enabled !== false,
start: normalizeTradingTimeSetting(row?.start, defaults.start),
end: normalizeTradingTimeSetting(row?.end, defaults.end),
}
}
return { mode, days }
} catch {
return null
}
}
export function getEffectiveTradingHours(): EffectiveTradingHours {
function isOutsideTimeWindow(now: Date, start: string, end: string): boolean {
const currentSeconds = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds()
const openSeconds = parseTimeToSeconds(start)
const closeSeconds = parseTimeToSeconds(end)
if (closeSeconds <= openSeconds) {
return currentSeconds < openSeconds && currentSeconds > closeSeconds
}
return currentSeconds < openSeconds || currentSeconds > closeSeconds
}
export function getStoreTradingHours(): StoreTradingHours {
const settings = useSettingsStore()
void settings.settingsRevision
return {
start: normalizeTradingTimeSetting(settings.getSettingByKey('startDay'), '00:00'),
end: normalizeTradingTimeSetting(settings.getSettingByKey('endDay'), '23:59'),
closePriceEnabled: settings.isSettingTruthy('close_price'),
}
}
export function getEffectiveTradingHours(now = new Date()): EffectiveTradingHours {
void productTradingHoursVersion.value
const settings = getStoreTradingHours()
const weekly = parseWeeklySchedule(useSettingsStore().getSettingByKey('trading_hours_by_day'))
if (settings.closePriceEnabled && weekly?.mode === 'per_day') {
const day = weekly.days[weekdayKey(now)]
if (day) {
return {
start: day.start,
end: day.end,
enforced: true,
dayEnabled: day.enabled,
}
}
}
if (settings.closePriceEnabled) {
return {
start: settings.start,
end: settings.end,
enforced: true,
dayEnabled: true,
}
}
@@ -112,6 +181,7 @@ export function getEffectiveTradingHours(): EffectiveTradingHours {
start: productTradingHours.value.start,
end: productTradingHours.value.end,
enforced: true,
dayEnabled: true,
}
}
@@ -119,20 +189,40 @@ export function getEffectiveTradingHours(): EffectiveTradingHours {
start: settings.start,
end: settings.end,
enforced: false,
dayEnabled: true,
}
}
export function isStoreClosedBySchedule(now = new Date()): boolean {
const { start, end, enforced } = getEffectiveTradingHours()
if (!enforced) {
/** خارج از ساعت کاری سراسری — فقط وقتی close_price روشن باشد (صفحه اصلی) */
export function isOutsideGlobalStoreHours(now = new Date()): boolean {
const settings = getStoreTradingHours()
if (!settings.closePriceEnabled) {
return false
}
const currentSeconds = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds()
const openSeconds = parseTimeToSeconds(start)
const closeSeconds = parseTimeToSeconds(end)
const weekly = parseWeeklySchedule(useSettingsStore().getSettingByKey('trading_hours_by_day'))
if (weekly?.mode === 'per_day') {
const day = weekly.days[weekdayKey(now)]
if (day && !day.enabled) {
return true
}
if (day) {
return isOutsideTimeWindow(now, day.start, day.end)
}
}
return currentSeconds < openSeconds || currentSeconds > closeSeconds
return isOutsideTimeWindow(now, settings.start, settings.end)
}
export function isStoreClosedBySchedule(now = new Date()): boolean {
const { start, end, enforced, dayEnabled } = getEffectiveTradingHours(now)
if (!enforced) {
return false
}
if (!dayEnabled) {
return true
}
return isOutsideTimeWindow(now, start, end)
}
export function isStoreClosed(): boolean {
@@ -167,35 +257,45 @@ export type PriceDisplayState = 'open' | 'closed' | 'no_quote'
export const NO_QUOTE_MESSAGE = 'مظنه در دسترس نیست'
/** سوییچ: خارج از ساعت کاری هم قیمت صفحه اصلی نشان داده شود */
export function shouldShowHomePriceWhenClosed(): boolean {
return useSettingsStore().isSettingTruthy('show_home_price_when_closed')
}
export function shouldShowLastSavedPrice(): boolean {
return useSettingsStore().isSettingTruthy('show_last_price')
}
function hasShowableHomePrice(p = lastPrice.value): boolean {
if (!p) return false
if (Number(p.buy) > 0 || Number(p.sell) > 0 || Number(p.price_last) > 0 || Number(p.price) > 0) {
return true
}
if (shouldShowLastSavedPrice() && Number((p as { last_historical?: number }).last_historical ?? 0) > 0) {
return true
}
return false
}
/**
* وضعیت نمایش قیمت در صفحه اصلی:
* - closed → فروشگاه بسته است (خارج از ساعت کاری close_price یا بازه محصول)
* - no_quote → مظنه در دسترس نیست (قیمت زنده نداریم و show_last_price خاموش است)
* - open → نمایش عادی قیمت
*
* اولویت با status ارسالی از سرور است؛ اگر نبود به منطق سمت کلاینت برمی‌گردیم.
* وضعیت نمایش قیمت در صفحه اصلی — همان منطق بک‌اند:
* close_price خاموش / داخل ساعت → قیمت هست open، وگرنه no_quote
* close_price روشن و خارج ساعت → show_home_price_when_closed روشن: آخرین قیمت؛ خاموش: closed
*/
export function getPriceDisplayState(): PriceDisplayState {
void productTradingHoursVersion.value
void useSettingsStore().settingsRevision
const p = lastPrice.value
const status = p?.status
const hasPrice = hasShowableHomePrice(p)
if (status === 'closed') {
return 'closed'
}
if (status === 'no_quote') {
return getTradeBuyPrice(p) > 0 ? 'open' : 'no_quote'
}
if (status === 'open') {
return getTradeBuyPrice(p) > 0 || getTradeSellPrice(p) > 0 ? 'open' : 'no_quote'
}
// سرور status نفرستاده (سازگاری با نسخه قدیمی) → استنتاج سمت کلاینت
if (isStoreClosed()) {
if (isOutsideGlobalStoreHours()) {
if (shouldShowHomePriceWhenClosed()) {
return hasPrice ? 'open' : 'no_quote'
}
return 'closed'
}
return getTradeBuyPrice(p) > 0 || getTradeSellPrice(p) > 0 ? 'open' : 'no_quote'
return hasPrice ? 'open' : 'no_quote'
}
export function getPriceDisplayMessage(state: PriceDisplayState = getPriceDisplayState()): string {