Fix score refresh, add granular fetch and live job status
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 35s
Deploy / deploy (push) Successful in 22s

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:
2026-06-14 13:10:15 +02:00
parent 3aebfd72d3
commit 316226096b
10 changed files with 296 additions and 94 deletions
+64 -10
View File
@@ -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}