Fix score refresh, add granular fetch and live job status
Scores never updated ("101d ago"): get_score only recomputes stale/
missing dimensions, but nothing marked them stale on new data, and there
was no scheduled scoring job.
- Fetch endpoint force-recomputes dimensions + composite.
- Scheduled scan (scan_all_tickers) refreshes scores per ticker, so
scores stay current globally, not just on manual fetch.
Granular fetch: /ingestion/fetch accepts a sources filter; the freshness
bar gets a per-row refresh button (OHLCV/Sentiment/Fundamentals fetch
that provider only — marked paid; S/R/Scores recompute for free). Header
button is now "Fetch All".
Job visibility: GET /jobs/running (any user) + sidebar live indicator
showing running scheduled jobs with progress, polled every 10s.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { useMemo, useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useTickerDetail } from '../hooks/useTickerDetail';
|
||||
import { useFetchSymbolData } from '../hooks/useFetchSymbolData';
|
||||
import type { FetchSelector } from '../api/ingestion';
|
||||
import { CandlestickChart } from '../components/charts/CandlestickChart';
|
||||
import { ScoreCard } from '../components/ui/ScoreCard';
|
||||
import { SkeletonCard } from '../components/ui/Skeleton';
|
||||
@@ -42,23 +43,51 @@ interface DataStatusItem {
|
||||
label: string;
|
||||
available: boolean;
|
||||
timestamp?: string | null;
|
||||
selector: FetchSelector; // what a refresh of this row fetches
|
||||
paid?: boolean; // provider call that may cost money/quota
|
||||
}
|
||||
|
||||
function DataFreshnessBar({ items }: { items: DataStatusItem[] }) {
|
||||
function RefreshIcon({ spinning }: { spinning: boolean }) {
|
||||
return (
|
||||
<div className="glass-sm p-3 flex flex-wrap gap-4">
|
||||
<svg className={`h-3.5 w-3.5 ${spinning ? 'animate-spin' : ''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h5M20 20v-5h-5M4 9a8 8 0 0114-3M20 15a8 8 0 01-14 3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DataFreshnessBar({
|
||||
items,
|
||||
onRefresh,
|
||||
pendingLabel,
|
||||
busy,
|
||||
}: {
|
||||
items: DataStatusItem[];
|
||||
onRefresh: (item: DataStatusItem) => void;
|
||||
pendingLabel: string | null;
|
||||
busy: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="glass-sm p-3 flex flex-wrap gap-x-5 gap-y-2">
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-2">
|
||||
<div key={item.label} className="flex items-center gap-1.5">
|
||||
<span className={`inline-block h-2 w-2 rounded-full shrink-0 ${
|
||||
item.available ? 'bg-emerald-400 shadow-lg shadow-emerald-400/40' : 'bg-gray-600'
|
||||
}`} />
|
||||
<span className="text-xs text-gray-400">{item.label}</span>
|
||||
{item.available && item.timestamp && (
|
||||
{item.available && item.timestamp ? (
|
||||
<span className="text-[10px] text-gray-500">{timeAgo(item.timestamp)}</span>
|
||||
)}
|
||||
{!item.available && (
|
||||
) : !item.available ? (
|
||||
<span className="text-[10px] text-gray-600">no data</span>
|
||||
)}
|
||||
) : null}
|
||||
<button
|
||||
onClick={() => onRefresh(item)}
|
||||
disabled={busy}
|
||||
title={item.paid ? `Fetch ${item.label} (uses provider quota)` : `Recompute ${item.label}`}
|
||||
className="ml-0.5 text-gray-500 hover:text-blue-300 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<RefreshIcon spinning={pendingLabel === item.label} />
|
||||
</button>
|
||||
{item.paid && <span className="text-[9px] text-amber-500/70" title="Uses a paid/quota provider call">$</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -70,35 +99,52 @@ export default function TickerDetailPage() {
|
||||
const { ohlcv, scores, srLevels, sentiment, fundamentals, trades } = useTickerDetail(symbol);
|
||||
const ingestion = useFetchSymbolData();
|
||||
const [activeTab, setActiveTab] = useState<DetailTab>('Analysis');
|
||||
const [refreshingLabel, setRefreshingLabel] = useState<string | null>(null);
|
||||
|
||||
const dataStatus: DataStatusItem[] = useMemo(() => [
|
||||
{
|
||||
label: 'OHLCV',
|
||||
available: !!ohlcv.data && ohlcv.data.length > 0,
|
||||
timestamp: ohlcv.data?.[ohlcv.data.length - 1]?.created_at,
|
||||
selector: ['ohlcv'] as FetchSelector,
|
||||
paid: true,
|
||||
},
|
||||
{
|
||||
label: 'Sentiment',
|
||||
available: !!sentiment.data && sentiment.data.count > 0,
|
||||
timestamp: sentiment.data?.scores?.[0]?.timestamp,
|
||||
selector: ['sentiment'] as FetchSelector,
|
||||
paid: true,
|
||||
},
|
||||
{
|
||||
label: 'Fundamentals',
|
||||
available: !!fundamentals.data && fundamentals.data.fetched_at !== null,
|
||||
timestamp: fundamentals.data?.fetched_at,
|
||||
selector: ['fundamentals'] as FetchSelector,
|
||||
paid: true,
|
||||
},
|
||||
{
|
||||
label: 'S/R Levels',
|
||||
available: !!srLevels.data && srLevels.data.count > 0,
|
||||
timestamp: srLevels.data?.levels?.[0]?.created_at,
|
||||
selector: 'recompute' as FetchSelector,
|
||||
},
|
||||
{
|
||||
label: 'Scores',
|
||||
available: !!scores.data && scores.data.composite_score !== null,
|
||||
timestamp: scores.data?.computed_at,
|
||||
selector: 'recompute' as FetchSelector,
|
||||
},
|
||||
], [ohlcv.data, sentiment.data, fundamentals.data, srLevels.data, scores.data]);
|
||||
|
||||
const handleRefresh = (item: DataStatusItem) => {
|
||||
setRefreshingLabel(item.label);
|
||||
ingestion.mutate(
|
||||
{ symbol, sources: item.selector },
|
||||
{ onSettled: () => setRefreshingLabel(null) },
|
||||
);
|
||||
};
|
||||
|
||||
// Log trades API errors but don't disrupt the page
|
||||
useEffect(() => {
|
||||
if (trades.error) {
|
||||
@@ -170,13 +216,21 @@ export default function TickerDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={() => ingestion.mutate(symbol)} loading={ingestion.isPending}>
|
||||
{ingestion.isPending ? 'Fetching…' : 'Fetch Data'}
|
||||
<Button
|
||||
onClick={() => { setRefreshingLabel(null); ingestion.mutate(symbol); }}
|
||||
loading={ingestion.isPending}
|
||||
>
|
||||
{ingestion.isPending ? 'Fetching…' : 'Fetch All'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Data freshness bar */}
|
||||
<DataFreshnessBar items={dataStatus} />
|
||||
<DataFreshnessBar
|
||||
items={dataStatus}
|
||||
onRefresh={handleRefresh}
|
||||
pendingLabel={refreshingLabel}
|
||||
busy={ingestion.isPending}
|
||||
/>
|
||||
|
||||
<RecommendationPanel
|
||||
symbol={symbol}
|
||||
|
||||
Reference in New Issue
Block a user