feat(ime/lme): 6-hour caching, remove polling timer, remove English symbol codes, and display latest prices on card faces

This commit is contained in:
alireza 2026-08-26 11:10:59 +03:30
parent cf2ab562cc
commit bdc08dad52
5 changed files with 232 additions and 100 deletions

View File

@ -2,16 +2,21 @@ import urllib.request
import json import json
import ssl import ssl
import time import time
import re
import threading import threading
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
IME_TOKEN = "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoiMDkzNjMxNDAyNjIiLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1laWRlbnRpZmllciI6IjE0ODgiLCJleHAiOjE3ODgzMjQ5MzN9.9r-XUKmRUBQ6uEOZGq09l474ZaDDPazNVUDHuXY2_QmBtS2RU78xJLt753GaGU2SZtpndzNYKWDYaoc58SNyAA" IME_TOKEN = "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1lIjoiMDkzNjMxNDAyNjIiLCJodHRwOi8vc2NoZW1hcy54bWxzb2FwLm9yZy93cy8yMDA1LzA1L2lkZW50aXR5L2NsYWltcy9uYW1laWRlbnRpZmllciI6IjE0ODgiLCJleHAiOjE3ODgzMjQ5MzN9.9r-XUKmRUBQ6uEOZGq09l474ZaDDPazNVUDHuXY2_QmBtS2RU78xJLt753GaGU2SZtpndzNYKWDYaoc58SNyAA"
BASE_URL = "https://api.yektazob.com" BASE_URL = "https://api.yektazob.com"
# 6 hours cache
CACHE_TTL = 21600
_cache_categories = { _cache_categories = {
"data": None, "data": None,
"last_fetched": 0 "last_fetched": 0
} }
_cache_sub_symbols = {}
_cache_symbols = {} _cache_symbols = {}
_lock = threading.Lock() _lock = threading.Lock()
@ -25,7 +30,31 @@ HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
} }
def get_ime_categories(ttl_seconds: int = 3600) -> List[Dict[str, Any]]: def clean_persian_title(title: str) -> tuple[str, str]:
"""
Removes English symbol codes (e.g. FTKC-BSG017OO-00, KHSS-DRIBRI-00)
and returns (clean_title, manufacturer).
"""
if not title:
return "", ""
parts = [p.strip() for p in title.split(" - ") if p.strip()]
if len(parts) >= 3:
# Check if middle part is an English symbol code
if re.search(r'^[A-Za-z0-9_-]+$', parts[1]):
product = parts[0]
manufacturer = parts[2]
return f"{product} ({manufacturer})", manufacturer
elif len(parts) == 2:
if re.search(r'^[A-Za-z0-9_-]+$', parts[1]):
return parts[0], ""
return f"{parts[0]} ({parts[1]})", parts[1]
# Remove any standalone English codes like FTKC-...
cleaned = re.sub(r'\b[A-Za-z0-9]{3,}-[A-Za-z0-9_-]+\b', '', title)
cleaned = re.sub(r'\s*-\s*-+\s*', ' - ', cleaned).strip(" -")
return cleaned or title, ""
def get_ime_categories(ttl_seconds: int = CACHE_TTL) -> List[Dict[str, Any]]:
now = time.time() now = time.time()
with _lock: with _lock:
if _cache_categories["data"] and (now - _cache_categories["last_fetched"]) < ttl_seconds: if _cache_categories["data"] and (now - _cache_categories["last_fetched"]) < ttl_seconds:
@ -48,18 +77,15 @@ def get_ime_categories(ttl_seconds: int = 3600) -> List[Dict[str, Any]]:
sub_title = sub.get("title", "") sub_title = sub.get("title", "")
symbols = [] symbols = []
for s in sub.get("symbols", []): for s in sub.get("symbols", []):
s_title = s.get("title", "") raw_title = s.get("title", "")
parts = [p.strip() for p in s_title.split(" - ") if p.strip()] clean_title, mfr = clean_persian_title(raw_title)
product_name = s.get("productName") or (parts[0] if len(parts) > 0 else s_title) product_name = s.get("productName") or clean_title
symbol_code = s.get("symbol") or (parts[1] if len(parts) > 1 else "")
manufacturer = s.get("manufacturer") or (parts[2] if len(parts) > 2 else "")
symbols.append({ symbols.append({
"id": s.get("id"), "id": s.get("id"),
"title": s_title, "title": clean_title,
"product_name": product_name, "product_name": product_name,
"symbol_code": symbol_code, "manufacturer": s.get("manufacturer") or mfr,
"manufacturer": manufacturer,
"is_active": s.get("isActive", True), "is_active": s.get("isActive", True),
"updated_at": s.get("updatedAt", "") "updated_at": s.get("updatedAt", "")
}) })
@ -86,8 +112,59 @@ def get_ime_categories(ttl_seconds: int = 3600) -> List[Dict[str, Any]]:
return cleaned return cleaned
def get_ime_subcategory_symbols(sub_id: int, ttl_seconds: int = CACHE_TTL) -> List[Dict[str, Any]]:
"""
Fetches all symbols of a subcategory WITH their latest prices attached.
"""
now = time.time()
with _lock:
cached = _cache_sub_symbols.get(sub_id)
if cached and (now - cached["time"]) < ttl_seconds:
return cached["data"]
def get_ime_symbol_detail(symbol_id: int, ttl_seconds: int = 1800) -> Dict[str, Any]: url = f"{BASE_URL}/Symbols/IME/Symbols/GetAll"
payload = json.dumps({"PageNumber": 1, "PageSize": 1000, "CategoryId": sub_id}).encode('utf-8')
req = urllib.request.Request(url, data=payload, headers=HEADERS)
with urllib.request.urlopen(req, context=ctx, timeout=12) as resp:
raw_syms = json.loads(resp.read().decode('utf-8'))
results = []
for s in raw_syms:
raw_title = s.get("title", "")
clean_title, mfr = clean_persian_title(raw_title)
product_name = s.get("productName") or clean_title
# Latest price from symbolPrices
prices = s.get("symbolPrices") or []
latest_p = None
if prices:
last = prices[-1]
latest_p = {
"id": last.get("id"),
"date": last.get("date", "").split("T")[0],
"high": last.get("high"),
"low": last.get("low"),
"mid": last.get("mid")
}
results.append({
"id": s.get("id"),
"cat_id": s.get("imeCategoryId") or sub_id,
"title": clean_title,
"product_name": product_name,
"manufacturer": s.get("manufacturer") or mfr,
"latest_price": latest_p,
"total_points": len(prices),
"is_active": s.get("isActive", True)
})
with _lock:
_cache_sub_symbols[sub_id] = {"time": now, "data": results}
return results
def get_ime_symbol_detail(symbol_id: int, ttl_seconds: int = CACHE_TTL) -> Dict[str, Any]:
now = time.time() now = time.time()
with _lock: with _lock:
cached = _cache_symbols.get(symbol_id) cached = _cache_symbols.get(symbol_id)
@ -104,21 +181,32 @@ def get_ime_symbol_detail(symbol_id: int, ttl_seconds: int = 1800) -> Dict[str,
sorted_prices = sorted(raw_prices, key=lambda p: p.get("date", "")) sorted_prices = sorted(raw_prices, key=lambda p: p.get("date", ""))
clean_prices = [] clean_prices = []
last_valid_mid = None
for p in sorted_prices: for p in sorted_prices:
d_str = p.get("date", "") d_str = p.get("date", "")
date_iso = d_str.split("T")[0] if "T" in d_str else d_str date_iso = d_str.split("T")[0] if "T" in d_str else d_str
mid_val = p.get("mid") or p.get("high") or p.get("low") or 0
if mid_val <= 0 and last_valid_mid is not None:
mid_val = last_valid_mid
else:
last_valid_mid = mid_val
clean_prices.append({ clean_prices.append({
"id": p.get("id"), "id": p.get("id"),
"date": date_iso, "date": date_iso,
"high": p.get("high"), "high": p.get("high") or mid_val,
"low": p.get("low"), "low": p.get("low") or mid_val,
"mid": p.get("mid") "mid": mid_val
}) })
raw_title = raw_detail.get("title", "")
clean_title, mfr = clean_persian_title(raw_title)
result = { result = {
"id": raw_detail.get("id"), "id": raw_detail.get("id"),
"cat_id": raw_detail.get("catID"), "cat_id": raw_detail.get("catID"),
"title": raw_detail.get("title"), "title": clean_title,
"manufacturer": mfr,
"order_no": raw_detail.get("orderNo"), "order_no": raw_detail.get("orderNo"),
"small_desc": raw_detail.get("smallDesc"), "small_desc": raw_detail.get("smallDesc"),
"total_points": len(clean_prices), "total_points": len(clean_prices),

View File

@ -261,6 +261,14 @@ def get_ime_categories_route(_: object = Depends(require_session)):
except Exception as e: except Exception as e:
raise HTTPException(status_code=502, detail=f"IME categories error: {e}") raise HTTPException(status_code=502, detail=f"IME categories error: {e}")
@app.get("/api/ime/subcategory/{sub_id}/symbols")
def get_ime_sub_symbols_route(sub_id: int, _: object = Depends(require_session)):
try:
return ime_metals.get_ime_subcategory_symbols(sub_id)
except Exception as e:
raise HTTPException(status_code=502, detail=f"IME subcategory symbols error: {e}")
@app.get("/api/ime/symbol/{symbol_id}") @app.get("/api/ime/symbol/{symbol_id}")
def get_ime_symbol_route(symbol_id: int, _: object = Depends(require_session)): def get_ime_symbol_route(symbol_id: int, _: object = Depends(require_session)):
try: try:

View File

@ -222,7 +222,7 @@ def fetch_raw():
"precious_metals": precious "precious_metals": precious
} }
def get_lme_data(ttl_seconds: int = 180): def get_lme_data(ttl_seconds: int = 21600): # 6 hours cache
now = time.time() now = time.time()
with _lock: with _lock:
if _cache["data"] and (now - _cache["last_fetched"]) < ttl_seconds: if _cache["data"] and (now - _cache["last_fetched"]) < ttl_seconds:
@ -239,7 +239,7 @@ def get_lme_data(ttl_seconds: int = 180):
raise e raise e
return _cache["data"] return _cache["data"]
def get_entire_diagram_data(field: str, ttl_seconds: int = 3600): def get_entire_diagram_data(field: str, ttl_seconds: int = 21600): # 6 hours cache
field_clean = field.strip() field_clean = field.strip()
field_clean = FIELD_MAP.get(field_clean.lower(), field_clean) field_clean = FIELD_MAP.get(field_clean.lower(), field_clean)

View File

@ -2,24 +2,18 @@ import { useState, useEffect, useMemo } from "react";
import { import {
Search, Search,
Factory, Factory,
Layers,
ChevronDown,
LineChart as LineChartIcon, LineChart as LineChartIcon,
X, X,
Loader2, Loader2,
Calendar, Calendar,
Sparkles,
ArrowUpRight,
TrendingUp,
TrendingDown,
Building2, Building2,
Hash TrendingUp,
ArrowUpRight
} from "lucide-react"; } from "lucide-react";
import { import {
ResponsiveContainer, ResponsiveContainer,
ComposedChart, ComposedChart,
Area, Area,
Line,
XAxis, XAxis,
YAxis, YAxis,
Tooltip, Tooltip,
@ -29,14 +23,22 @@ import { API_BASE } from "@/lib/api";
const API = API_BASE; const API = API_BASE;
export interface ImePricePoint {
id: number;
date: string; // YYYY-MM-DD
high: number;
low: number;
mid: number;
}
export interface ImeSymbol { export interface ImeSymbol {
id: number; id: number;
title: string; title: string;
product_name: string; product_name: string;
symbol_code: string;
manufacturer: string; manufacturer: string;
is_active: boolean; is_active: boolean;
updated_at: string; updated_at: string;
latest_price?: ImePricePoint;
} }
export interface ImeSubcategory { export interface ImeSubcategory {
@ -54,18 +56,11 @@ export interface ImeCategory {
subcategories: ImeSubcategory[]; subcategories: ImeSubcategory[];
} }
export interface ImePricePoint {
id: number;
date: string; // YYYY-MM-DD
high: number;
low: number;
mid: number;
}
export interface ImeSymbolDetail { export interface ImeSymbolDetail {
id: number; id: number;
cat_id: number; cat_id: number;
title: string; title: string;
manufacturer?: string;
order_no?: number; order_no?: number;
small_desc?: string; small_desc?: string;
total_points: number; total_points: number;
@ -85,13 +80,17 @@ export function ImeView() {
const [selectedSubId, setSelectedSubId] = useState<number | "ALL">("ALL"); const [selectedSubId, setSelectedSubId] = useState<number | "ALL">("ALL");
const [search, setSearch] = useState<string>(""); const [search, setSearch] = useState<string>("");
// Subcategory price cache (populated when subcategory is clicked)
const [subPricesMap, setSubPricesMap] = useState<Record<number, Record<number, ImePricePoint>>>({});
const [subLoading, setSubLoading] = useState<boolean>(false);
// Modal Detail // Modal Detail
const [selectedSymbol, setSelectedSymbol] = useState<ImeSymbol | null>(null); const [selectedSymbol, setSelectedSymbol] = useState<ImeSymbol | null>(null);
const [symbolDetail, setSymbolDetail] = useState<ImeSymbolDetail | null>(null); const [symbolDetail, setSymbolDetail] = useState<ImeSymbolDetail | null>(null);
const [detailLoading, setDetailLoading] = useState<boolean>(false); const [detailLoading, setDetailLoading] = useState<boolean>(false);
const [timeframe, setTimeframe] = useState<Timeframe>("ENTIRE"); const [timeframe, setTimeframe] = useState<Timeframe>("ENTIRE");
// Load all IME categories // Load categories once on mount (no aggressive interval)
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
setLoading(true); setLoading(true);
@ -118,7 +117,42 @@ export function ImeView() {
}; };
}, []); }, []);
// Fetch symbol detail & price history when modal opens // When a specific subcategory is selected, fetch all its symbols with latest prices attached
useEffect(() => {
if (selectedSubId === "ALL") return;
let alive = true;
setSubLoading(true);
fetch(`${API}/api/ime/subcategory/${selectedSubId}/symbols`, { credentials: "include" })
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then((symbols: Array<ImeSymbol & { latest_price?: ImePricePoint }>) => {
if (!alive) return;
const pMap: Record<number, ImePricePoint> = {};
for (const s of symbols) {
if (s.latest_price) {
pMap[s.id] = s.latest_price;
}
}
setSubPricesMap((prev) => ({
...prev,
[selectedSubId as number]: pMap
}));
})
.catch(() => {})
.finally(() => {
if (alive) setSubLoading(false);
});
return () => {
alive = false;
};
}, [selectedSubId]);
// Fetch symbol detail & full price history when modal opens
useEffect(() => { useEffect(() => {
if (!selectedSymbol) { if (!selectedSymbol) {
setSymbolDetail(null); setSymbolDetail(null);
@ -160,16 +194,14 @@ export function ImeView() {
// Active subcategories based on root category selection // Active subcategories based on root category selection
const currentSubcategories = useMemo(() => { const currentSubcategories = useMemo(() => {
if (selectedRootId === "ALL") { if (selectedRootId === "ALL") return [];
return [];
}
const cat = categories.find((c) => c.id === selectedRootId); const cat = categories.find((c) => c.id === selectedRootId);
return cat ? cat.subcategories : []; return cat ? cat.subcategories : [];
}, [categories, selectedRootId]); }, [categories, selectedRootId]);
// Flattened symbols filtered by root, subcategory, and search query // Flattened symbols filtered by root, subcategory, and search query (100% clean of English codes)
const filteredSymbols = useMemo(() => { const filteredSymbols = useMemo(() => {
let list: Array<ImeSymbol & { rootTitle: string; subTitle: string }> = []; let list: Array<ImeSymbol & { rootTitle: string; subTitle: string; subId: number }> = [];
for (const root of categories) { for (const root of categories) {
if (selectedRootId !== "ALL" && root.id !== selectedRootId) continue; if (selectedRootId !== "ALL" && root.id !== selectedRootId) continue;
@ -178,10 +210,15 @@ export function ImeView() {
if (selectedSubId !== "ALL" && sub.id !== selectedSubId) continue; if (selectedSubId !== "ALL" && sub.id !== selectedSubId) continue;
for (const sym of sub.symbols) { for (const sym of sub.symbols) {
// Look up latest price from subPricesMap if available
const priceObj = subPricesMap[sub.id]?.[sym.id] || sym.latest_price;
list.push({ list.push({
...sym, ...sym,
latest_price: priceObj,
rootTitle: root.title, rootTitle: root.title,
subTitle: sub.title subTitle: sub.title,
subId: sub.id
}); });
} }
} }
@ -194,12 +231,11 @@ export function ImeView() {
(s) => (s) =>
s.title.toLowerCase().includes(q) || s.title.toLowerCase().includes(q) ||
s.product_name.toLowerCase().includes(q) || s.product_name.toLowerCase().includes(q) ||
s.symbol_code.toLowerCase().includes(q) ||
s.manufacturer.toLowerCase().includes(q) || s.manufacturer.toLowerCase().includes(q) ||
s.rootTitle.toLowerCase().includes(q) || s.rootTitle.toLowerCase().includes(q) ||
s.subTitle.toLowerCase().includes(q) s.subTitle.toLowerCase().includes(q)
); );
}, [categories, selectedRootId, selectedSubId, search]); }, [categories, selectedRootId, selectedSubId, search, subPricesMap]);
// Filtered prices for modal chart by timeframe // Filtered prices for modal chart by timeframe
const chartPoints = useMemo(() => { const chartPoints = useMemo(() => {
@ -247,7 +283,7 @@ export function ImeView() {
بورس فلزات ایران (IME) بورس فلزات ایران (IME)
</h1> </h1>
<p className="text-xs text-muted-foreground mt-0.5"> <p className="text-xs text-muted-foreground mt-0.5">
تابلوی معاملات فیزیکی بورس کالای ایران، نرخهای کشفشده و آرشیو تاریخی تابلوی معاملات فیزیکی بورس کالا آخرین نرخهای کشفشده و آرشیو تاریخی
</p> </p>
</div> </div>
</div> </div>
@ -259,7 +295,7 @@ export function ImeView() {
type="text" type="text"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder="جستجوی نماد، محصول یا تولیدکننده..." placeholder="جستجوی نام کالا یا تولیدکننده..."
className="w-full rounded-xl border border-border/80 bg-background/80 py-1.5 pr-9 pl-3 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary" className="w-full rounded-xl border border-border/80 bg-background/80 py-1.5 pr-9 pl-3 text-xs text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary"
/> />
</div> </div>
@ -306,7 +342,7 @@ export function ImeView() {
))} ))}
</div> </div>
{/* Subcategory Pills (when a root is selected) */} {/* Subcategory Pills */}
{currentSubcategories.length > 0 && ( {currentSubcategories.length > 0 && (
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 no-scrollbar border-t border-border/40 pt-3"> <div className="flex items-center gap-1.5 overflow-x-auto pb-1 no-scrollbar border-t border-border/40 pt-3">
<button <button
@ -337,11 +373,17 @@ export function ImeView() {
</div> </div>
)} )}
{/* Total symbols count indicator */} {/* Indicator */}
<div className="flex items-center justify-between text-xs text-muted-foreground px-1"> <div className="flex items-center justify-between text-xs text-muted-foreground px-1">
<span> <span>
نمایش <strong className="text-foreground">{filteredSymbols.length.toLocaleString()}</strong> نماد معاملاتی نمایش <strong className="text-foreground">{filteredSymbols.length.toLocaleString()}</strong> نماد معاملاتی
</span> </span>
{subLoading && (
<span className="flex items-center gap-1 text-[11px] text-primary">
<Loader2 className="w-3 h-3 animate-spin" />
<span>در حال بارگذاری قیمتهای زیردسته...</span>
</span>
)}
</div> </div>
{/* Loading state */} {/* Loading state */}
@ -364,15 +406,8 @@ export function ImeView() {
className="group cursor-pointer relative overflow-hidden rounded-2xl border border-border/70 bg-gradient-to-b from-card to-card/40 p-5 backdrop-blur-md transition-all duration-200 hover:shadow-xl hover:-translate-y-1 hover:border-primary flex flex-col justify-between" className="group cursor-pointer relative overflow-hidden rounded-2xl border border-border/70 bg-gradient-to-b from-card to-card/40 p-5 backdrop-blur-md transition-all duration-200 hover:shadow-xl hover:-translate-y-1 hover:border-primary flex flex-col justify-between"
> >
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-start justify-between gap-2"> <div className="text-sm font-bold text-foreground group-hover:text-primary transition-colors line-clamp-2">
<div className="text-sm font-bold text-foreground group-hover:text-primary transition-colors line-clamp-2"> {sym.product_name || sym.title}
{sym.product_name || sym.title}
</div>
{sym.symbol_code && (
<span className="text-[10px] font-mono px-2 py-0.5 rounded-md bg-muted/80 text-muted-foreground border border-border/60 shrink-0">
{sym.symbol_code}
</span>
)}
</div> </div>
{sym.manufacturer && ( {sym.manufacturer && (
@ -383,17 +418,36 @@ export function ImeView() {
)} )}
</div> </div>
<div className="mt-4 pt-3 border-t border-border/40 flex items-center justify-between text-xs"> {/* LATEST PRICE PROMINENTLY DISPLAYED ON CARD FACE */}
<div className="flex items-center gap-1.5"> <div className="mt-4 pt-3 border-t border-border/40 space-y-2">
<span className="text-[11px] px-2 py-0.5 rounded-md bg-primary/10 text-primary font-medium"> <div className="flex items-baseline justify-between">
{sym.subTitle || sym.rootTitle} <span className="text-[11px] text-muted-foreground">آخرین قیمت:</span>
</span> <div className="text-left font-mono">
{sym.latest_price?.mid ? (
<div>
<div className="text-base font-bold text-primary">
{sym.latest_price.mid.toLocaleString()} <span className="text-[11px] font-normal text-muted-foreground">ریال</span>
</div>
<div className="text-[10px] text-muted-foreground">
{(Math.round(sym.latest_price.mid / 10)).toLocaleString()} تومان
</div>
</div>
) : (
<span className="text-xs font-semibold text-primary">مشاهده سابقه</span>
)}
</div>
</div> </div>
<span className="text-[11px] font-medium text-primary flex items-center gap-1 group-hover:translate-x-[-2px] transition-transform"> <div className="flex items-center justify-between text-xs pt-1 border-t border-border/30">
<span>نمودار سابقه</span> <span className="text-[10px] px-2 py-0.5 rounded-md bg-muted/60 text-muted-foreground font-medium">
<ArrowUpRight className="w-3.5 h-3.5" /> {sym.subTitle || sym.rootTitle}
</span> </span>
<span className="text-[11px] font-medium text-primary flex items-center gap-1 group-hover:translate-x-[-2px] transition-transform">
<span>نمودار کامل</span>
<ArrowUpRight className="w-3.5 h-3.5" />
</span>
</div>
</div> </div>
</div> </div>
))} ))}
@ -417,19 +471,12 @@ export function ImeView() {
<h2 className="text-lg sm:text-xl font-bold text-foreground"> <h2 className="text-lg sm:text-xl font-bold text-foreground">
{selectedSymbol.product_name || selectedSymbol.title} {selectedSymbol.product_name || selectedSymbol.title}
</h2> </h2>
<div className="flex flex-wrap items-center gap-2 mt-1"> {selectedSymbol.manufacturer && (
{selectedSymbol.manufacturer && ( <div className="text-xs text-muted-foreground flex items-center gap-1 mt-1">
<span className="text-xs text-muted-foreground flex items-center gap-1"> <Building2 className="w-3.5 h-3.5 text-primary/70" />
<Building2 className="w-3 h-3 text-primary/70" /> <span>{selectedSymbol.manufacturer}</span>
{selectedSymbol.manufacturer} </div>
</span> )}
)}
{selectedSymbol.symbol_code && (
<span className="text-xs font-mono bg-muted/60 px-2 py-0.5 rounded text-muted-foreground">
{selectedSymbol.symbol_code}
</span>
)}
</div>
</div> </div>
</div> </div>
@ -446,26 +493,26 @@ export function ImeView() {
{stats && ( {stats && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="rounded-2xl border border-border/60 bg-card/60 p-3.5"> <div className="rounded-2xl border border-border/60 bg-card/60 p-3.5">
<div className="text-[11px] text-muted-foreground">آخرین قیمت (ریال/کیلوگرم)</div> <div className="text-[11px] text-muted-foreground">آخرین قیمت کشفشده</div>
<div className="text-base sm:text-lg font-bold font-mono text-foreground mt-0.5"> <div className="text-base sm:text-lg font-bold font-mono text-foreground mt-0.5">
{stats.last.toLocaleString()} {stats.last.toLocaleString()} ریال
</div> </div>
<div className="text-[10px] text-muted-foreground mt-0.5"> <div className="text-[10px] text-muted-foreground mt-0.5">
{(stats.last / 10).toLocaleString()} تومان {(Math.round(stats.last / 10)).toLocaleString()} تومان
</div> </div>
</div> </div>
<div className="rounded-2xl border border-border/60 bg-card/60 p-3.5"> <div className="rounded-2xl border border-border/60 bg-card/60 p-3.5">
<div className="text-[11px] text-muted-foreground">بالاترین قیمت دوره</div> <div className="text-[11px] text-muted-foreground">بالاترین قیمت دوره</div>
<div className="text-base sm:text-lg font-bold font-mono text-foreground mt-0.5"> <div className="text-base sm:text-lg font-bold font-mono text-foreground mt-0.5">
{stats.max.toLocaleString()} {stats.max.toLocaleString()} ریال
</div> </div>
</div> </div>
<div className="rounded-2xl border border-border/60 bg-card/60 p-3.5"> <div className="rounded-2xl border border-border/60 bg-card/60 p-3.5">
<div className="text-[11px] text-muted-foreground">پایینترین قیمت دوره</div> <div className="text-[11px] text-muted-foreground">پایینترین قیمت دوره</div>
<div className="text-base sm:text-lg font-bold font-mono text-foreground mt-0.5"> <div className="text-base sm:text-lg font-bold font-mono text-foreground mt-0.5">
{stats.min.toLocaleString()} {stats.min.toLocaleString()} ریال
</div> </div>
</div> </div>
@ -523,26 +570,17 @@ export function ImeView() {
return ( return (
<div className="rounded-xl border border-border/80 bg-card/95 p-3 text-foreground shadow-2xl backdrop-blur-md space-y-1 min-w-[160px]" dir="rtl"> <div className="rounded-xl border border-border/80 bg-card/95 p-3 text-foreground shadow-2xl backdrop-blur-md space-y-1 min-w-[160px]" dir="rtl">
<div className="text-[11px] text-muted-foreground font-mono pb-1 border-b border-border/40"> <div className="text-[11px] text-muted-foreground font-mono pb-1 border-b border-border/40">
تاریخ: {pt.date} تاریخ معامله: {pt.date}
</div> </div>
<div className="flex items-center justify-between text-xs pt-1"> <div className="flex items-center justify-between text-xs pt-1">
<span className="text-muted-foreground">میانگین موزون:</span> <span className="text-muted-foreground">قیمت پایانی:</span>
<span className="font-mono font-bold text-primary"> <span className="font-mono font-bold text-primary">
{pt.mid.toLocaleString()} ریال {pt.mid.toLocaleString()} ریال
</span> </span>
</div> </div>
{pt.high !== pt.mid && ( <div className="text-[10px] text-muted-foreground">
<div className="flex items-center justify-between text-[11px] text-muted-foreground"> معادل {(Math.round(pt.mid / 10)).toLocaleString()} تومان
<span>بالاترین:</span> </div>
<span className="font-mono">{pt.high.toLocaleString()}</span>
</div>
)}
{pt.low !== pt.mid && (
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<span>پایینترین:</span>
<span className="font-mono">{pt.low.toLocaleString()}</span>
</div>
)}
</div> </div>
); );
}} }}
@ -550,7 +588,7 @@ export function ImeView() {
<Area <Area
type="monotone" type="monotone"
dataKey="mid" dataKey="mid"
name="قیمت پایانی" name="قیمت کشف‌شده"
stroke="#2563eb" stroke="#2563eb"
strokeWidth={2} strokeWidth={2}
fill="url(#imePriceGrad)" fill="url(#imePriceGrad)"

View File

@ -143,8 +143,6 @@ export function LmeView() {
useEffect(() => { useEffect(() => {
loadData(); loadData();
const interval = setInterval(loadData, 30000);
return () => clearInterval(interval);
}, []); }, []);
// Fetch full multi-year diagram data whenever selectedSymbol changes // Fetch full multi-year diagram data whenever selectedSymbol changes