Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 32 additions & 10 deletions fintick/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,12 +202,14 @@ def read_feed(database: str | Path, *, limit: int = DEFAULT_LIMIT) -> dict[str,
.tape{flex:1 1 auto;min-width:0;min-height:46px;overflow:hidden;display:flex;align-items:center;border:1px solid var(--line);background:var(--panel);white-space:nowrap;-webkit-mask-image:linear-gradient(90deg,transparent,#000 6%,#000 94%,transparent);mask-image:linear-gradient(90deg,transparent,#000 6%,#000 94%,transparent)}.tape-track{display:inline-flex;min-width:max-content;animation:crawl var(--duration,50s) linear infinite}.tape:hover .tape-track{animation-play-state:paused}.tape-group{display:inline-flex}.tick{font-size:12.5px;color:var(--muted)}.tick::before{content:"◆";color:var(--line);margin:0 20px}.tick.breaking{color:var(--breaking)}.tick.confirmed{color:var(--confirmed)}.tick.unconfirmed{color:var(--unconfirmed)}
a.tick{text-decoration:none;cursor:pointer}a.tick:hover{text-decoration:underline;text-underline-offset:3px}a.tick:focus-visible{outline:2px solid var(--amber);outline-offset:2px}
@keyframes crawl{to{transform:translateX(-50%)}}
main{width:min(1320px,calc(100% - 32px));margin:0 auto;padding:38px 0 76px}.board-head{display:flex;justify-content:space-between;align-items:end;gap:20px;margin-bottom:18px}.board-head h2{margin:0;font-size:15px;letter-spacing:.15em;text-transform:uppercase}.board-head p{margin:7px 0 0;color:var(--muted);font-size:11px}.updated{font-size:10px;color:var(--dim);font-variant-numeric:tabular-nums}
.feed{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,440px),1fr));gap:16px}.card{position:relative;min-width:0;padding:22px;border:1px solid var(--line);border-top:3px solid var(--state);background:linear-gradient(145deg,var(--card-sheen),transparent 45%),var(--surface)}.card.breaking{--state:var(--breaking);background:linear-gradient(145deg,color-mix(in srgb,var(--breaking) 12%,transparent),transparent 48%),var(--surface);box-shadow:0 0 0 1px color-mix(in srgb,var(--breaking) 12%,transparent),0 15px 50px var(--shadow)}.card.confirmed{--state:var(--confirmed)}.card.developing{--state:var(--developing)}.card.contradicted{--state:var(--contradicted)}.card.unconfirmed{--state:var(--unconfirmed)}
.card{scroll-margin-top:164px}.card:target{box-shadow:0 0 0 2px var(--amber),0 14px 44px var(--shadow);animation:flash 1.1s ease-out}@keyframes flash{0%{box-shadow:0 0 0 3px var(--amber),0 0 30px var(--amber)}100%{box-shadow:0 0 0 2px var(--amber),0 14px 44px var(--shadow)}}
main{width:100%;margin:0;padding:38px clamp(18px,4vw,60px) 76px}.board-head{display:flex;justify-content:space-between;align-items:end;gap:20px;margin-bottom:18px}.board-head h2{margin:0;font-size:15px;letter-spacing:.15em;text-transform:uppercase}.board-head p{margin:7px 0 0;color:var(--muted);font-size:11px}.updated{font-size:10px;color:var(--dim);font-variant-numeric:tabular-nums}
/* Masonry rows: 1px keeps span quantization sub-pixel. Row spacing is the child
margin, not row-gap — a row-gap is charged per row and would coarsen the grid. */
.feed{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,360px),1fr));grid-auto-rows:1px;align-items:start;column-gap:16px;row-gap:0}.feed>*{margin-bottom:16px}.card{position:relative;min-width:0;padding:22px;border:1px solid var(--line);border-top:3px solid var(--state);background:linear-gradient(145deg,var(--card-sheen),transparent 45%),var(--surface)}.card.breaking{--state:var(--breaking);background:linear-gradient(145deg,color-mix(in srgb,var(--breaking) 12%,transparent),transparent 48%),var(--surface);box-shadow:0 0 0 1px color-mix(in srgb,var(--breaking) 12%,transparent),0 15px 50px var(--shadow)}.card.confirmed{--state:var(--confirmed)}.card.developing{--state:var(--developing)}.card.contradicted{--state:var(--contradicted)}.card.unconfirmed{--state:var(--unconfirmed)}
.card{scroll-margin-top:calc(var(--header-h,164px) + 14px)}.card:target{box-shadow:0 0 0 2px var(--amber),0 14px 44px var(--shadow);animation:flash 1.1s ease-out}@keyframes flash{0%{box-shadow:0 0 0 3px var(--amber),0 0 30px var(--amber)}100%{box-shadow:0 0 0 2px var(--amber),0 14px 44px var(--shadow)}}
.badge{display:inline-flex;align-items:center;min-height:32px;padding:7px 10px;border:1px solid color-mix(in srgb,var(--state) 55%,transparent);background:color-mix(in srgb,var(--state) 11%,transparent);color:var(--state);font-size:10px;font-weight:700;letter-spacing:.09em;text-transform:uppercase}.badge::before{content:"";width:7px;height:7px;border-radius:50%;margin-right:8px;background:var(--state);box-shadow:0 0 9px var(--state)}
.card-meta{display:flex;justify-content:space-between;gap:16px;align-items:start;margin-bottom:16px}.importance{color:var(--amber);font-size:9px;letter-spacing:2px}.importance i{color:color-mix(in srgb,var(--text) 22%,transparent);font-style:normal}.headline{margin:0 0 10px;font:650 clamp(17px,1.7vw,21px)/1.38 "IBM Plex Mono","SFMono-Regular",Consolas,monospace;overflow-wrap:anywhere}.summary{margin:0;color:color-mix(in srgb,var(--text) 78%,transparent);font-size:13px;line-height:1.6}.origin{margin-top:14px;color:var(--muted);font-size:10px;letter-spacing:.04em}.origin strong{color:var(--text)}
.facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(145px,1fr));gap:8px;margin-top:18px}.fact{padding:10px;border:1px solid var(--inset-line);background:var(--lift)}.fact b{display:block;color:var(--text);font-size:14px;margin-bottom:3px}.fact span{color:var(--muted);font-size:9px;text-transform:uppercase;letter-spacing:.08em}
.facts{display:grid;grid-template-columns:repeat(auto-fit,minmax(145px,1fr));grid-auto-rows:1px;align-items:start;column-gap:8px;row-gap:0;margin-top:18px}.fact{margin-bottom:8px;padding:10px;border:1px solid var(--inset-line);background:var(--lift)}.fact b{display:block;color:var(--text);font-size:14px;margin-bottom:3px}.fact span{color:var(--muted);font-size:9px;text-transform:uppercase;letter-spacing:.08em}
.chips{display:flex;flex-wrap:wrap;gap:7px;margin-top:14px}.chip{padding:5px 8px;border:1px solid var(--inset-line);background:var(--lift);font-size:10px}.chip.up{color:var(--confirmed);border-color:color-mix(in srgb,var(--confirmed) 45%,var(--line))}.chip.down{color:var(--breaking);border-color:color-mix(in srgb,var(--breaking) 45%,var(--line))}
.sources{margin:18px 0 0;padding:15px 0 0;border-top:1px solid var(--line);list-style:none}.sources li+li{margin-top:9px}.sources a{color:var(--cyan);font-size:11px;line-height:1.45;text-decoration:underline;text-decoration-color:rgba(117,203,208,.35);text-underline-offset:3px}.sources a:focus-visible{outline:2px solid var(--amber);outline-offset:3px}.publisher{color:var(--muted);font-size:10px}.lag{margin-top:12px;color:var(--confirmed);font-size:10px}.empty{grid-column:1/-1;padding:90px 24px;border:1px dashed var(--line);color:var(--muted);text-align:center;line-height:1.8}.error-message{color:var(--breaking)}
footer{padding:20px;border-top:1px solid var(--line);color:var(--dim);text-align:center;font-size:9px;letter-spacing:.1em}
Expand Down Expand Up @@ -248,7 +250,11 @@ def read_feed(database: str | Path, *, limit: int = DEFAULT_LIMIT) -> dict[str,
function renderCost(cost){const node=$('cost');if(!node)return;node.replaceChildren();const c=cost&&typeof cost==='object'?cost:{},labels={hour:'1H',day:'24H',week:'7D',month:'30D'};node.append(element('span','lead','inference cost'));for(const key of ['hour','day','week','month']){const w=c[key]||{},usd=Number(w.usd)||0,calls=Number(w.calls)||0,span=element('span','');span.append(document.createTextNode(labels[key]+' '),element('b','','$'+usd.toFixed(usd<1?4:2)));if(calls)span.append(element('span','calls',' ('+calls+' call'+(calls===1?'':'s')+')'));node.append(span)}}
const FILTERS=[['all','all'],['breaking','breaking'],['unconfirmed','unconfirmed'],['developing','developing'],['confirmed','confirmed'],['contradicted','contradicted']];
const activeFilters=new Set();
function statusCount(items,status){return status==='all'?items.length:items.filter(x=>x.status===status).length}
// Contradicted events are off the board unless their pill is selected. The pill still
// carries the real count, so hidden never means gone.
const DEFAULT_HIDDEN=new Set(['contradicted']);
function boardPool(items){return items.filter(x=>!DEFAULT_HIDDEN.has(x.status)||activeFilters.has(x.status))}
function statusCount(items,status){return status==='all'?boardPool(items).length:items.filter(x=>x.status===status).length}
function renderMetrics(items){const metrics=$('metrics');metrics.replaceChildren();for(const [status,label] of FILTERS){const on=status==='all'?activeFilters.size===0:activeFilters.has(status),box=element('button','metric '+status+(on?' active':''));box.type='button';box.setAttribute('aria-pressed',String(on));box.append(element('span','',label),element('b','',String(statusCount(items,status))));box.addEventListener('click',()=>{if(status==='all')activeFilters.clear();else if(activeFilters.has(status))activeFilters.delete(status);else activeFilters.add(status);applyFilter()});metrics.append(box)}}
function tick(item,hidden){const cls='tick '+safeStatus(item.status),text=String(item.headline||'');if(item.id===undefined||item.id===null)return element('span',cls,text);const link=element('a',cls,text);link.href='#evt-'+item.id;link.title='Jump to this event';if(hidden)link.tabIndex=-1;return link}
function renderTape(items){const tape=$('tape');tape.replaceChildren();if(!items.length){tape.append(element('span','tick','No events yet'));return}function group(hidden){const node=element('div','tape-group');if(hidden)node.setAttribute('aria-hidden','true');for(const item of items.slice(0,24))node.append(tick(item,hidden));return node}tape.append(group(false),group(true));tape.style.setProperty('--duration',Math.max(38,items.length*8)+'s')}
Expand All @@ -257,17 +263,33 @@ def read_feed(database: str | Path, *, limit: int = DEFAULT_LIMIT) -> dict[str,
const instruments=Array.isArray(item.instruments)?item.instruments:[];if(instruments.length){const chips=element('div','chips');for(const instrument of instruments){const direction=['up','down','flat'].includes(instrument.direction)?instrument.direction:'flat';const marker=direction==='up'?'▲ ':direction==='down'?'▼ ':'— ';const chip=element('span','chip '+direction,marker+String(instrument.symbol||instrument.name||''));chip.title=String(instrument.name||instrument.symbol||'Instrument');chips.append(chip)}card.append(chips)}
const links=Array.isArray(item.validations)?item.validations:[];if(links.length){const list=element('ul','sources');for(const story of links){if(!story||!story.url)continue;const li=element('li'),link=element('a','',String(story.title||story.url));link.href=String(story.url);link.target='_blank';link.rel='noopener noreferrer';li.append(link,document.createTextNode(' '),element('span','publisher','— '+String(story.publisher||'external news')));list.append(li)}if(list.childNodes.length)card.append(list)}const lag=lagText(item.lead_seconds);if(lag)card.append(element('p','lag',lag));return card}
let lastItems=[],lastGeneratedAt=null;
function visibleItems(){return activeFilters.size===0?lastItems:lastItems.filter(x=>activeFilters.has(x.status))}
function renderFeed(){const feed=$('feed'),items=visibleItems(),sel=[...activeFilters];feed.replaceChildren();if(!items.length)feed.append(element('div','empty',lastItems.length?('No '+(sel.length?sel.join(' / ')+' ':'')+'events to show.'):'No aggregated events yet. Ingest the stream, then run FinTick aggregate.'));else for(const item of items)feed.append(renderCard(item));const generated=new Date(lastGeneratedAt),total=lastItems.length,shown=items.length,count=sel.length===0?total+' events':shown+' of '+total+' · '+sel.join(' + '),stamp=Number.isNaN(generated.valueOf())?'':' · updated '+generated.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'});$('updated').textContent=count+stamp}
function visibleItems(){const pool=boardPool(lastItems);return activeFilters.size===0?pool:pool.filter(x=>activeFilters.has(x.status))}
function renderFeed(){const items=visibleItems(),sel=[...activeFilters];setFeed(items.length?items.map(item=>renderCard(item)):[element('div','empty',lastItems.length?('No '+(sel.length?sel.join(' / ')+' ':'')+'events to show.'):'No aggregated events yet. Ingest the stream, then run FinTick aggregate.')]);const generated=new Date(lastGeneratedAt),total=boardPool(lastItems).length,shown=items.length,count=sel.length===0?total+' events':shown+' of '+total+' · '+sel.join(' + '),stamp=Number.isNaN(generated.valueOf())?'':' · updated '+generated.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'});$('updated').textContent=count+stamp}
// Every #feed mutation goes through setFeed: the CSS declares 1px rows, so a node
// added without a matching row span collapses to 1px and paints over the footer.
function setFeed(nodes){const feed=$('feed');feed.replaceChildren();for(const node of nodes)feed.append(node);layoutMasonry()}
// Pack one 1px-row grid: measure every child, give it a row span matching its own
// height. Used for both the board and the fact grids inside each card.
function packGrid(grid){const kids=[...grid.children];if(!kids.length)return;const styles=getComputedStyle(grid),row=parseFloat(styles.gridAutoRows)||1,gap=parseFloat(styles.rowGap)||0,spacer=parseFloat(getComputedStyle(kids[0]).marginBottom)||0;for(const k of kids)k.style.gridRowEnd='';const heights=kids.map(k=>k.getBoundingClientRect().height);kids.forEach((k,i)=>{const h=heights[i];if(h)k.style.gridRowEnd='span '+Math.max(1,Math.ceil((h+spacer+gap)/(row+gap)))})}
function unpackGrid(grid){for(const k of grid.children)k.style.gridRowEnd='';grid.style.gridAutoRows='auto'}
function layoutMasonry(){const feed=$('feed');if(!feed)return;const cards=[...feed.children];if(!cards.length)return;const factGrids=[...feed.querySelectorAll('.facts')];try{
// Facts pack FIRST: reclaiming their dead space shortens the card, so measuring
// cards before this would bake in the taller pre-pack height and leave a gap.
for(const grid of factGrids)packGrid(grid);packGrid(feed);feed.style.gridAutoRows=''}catch(error){/* Half-spanned cards overlap and are unreadable; a plain grid is not. */
for(const grid of factGrids)unpackGrid(grid);unpackGrid(feed)}}
function syncHeaderOffset(){const header=document.querySelector('header');if(!header)return;const h=Math.round(header.getBoundingClientRect().height);if(h)document.documentElement.style.setProperty('--header-h',h+'px')}
addEventListener('resize',syncHeaderOffset);syncHeaderOffset();
function applyFilter(){renderMetrics(lastItems);renderFeed()}
function render(data){lastItems=Array.isArray(data.items)?data.items:[];lastGeneratedAt=data.generated_at;renderPipeline(data.pipeline);renderTape(lastItems);applyFilter()}
function render(data){lastItems=Array.isArray(data.items)?data.items:[];lastGeneratedAt=data.generated_at;renderPipeline(data.pipeline);renderTape(boardPool(lastItems));applyFilter();syncHeaderOffset()}
function currentTheme(){const set=document.documentElement.dataset.theme;if(set==='light'||set==='dark')return set;return matchMedia('(prefers-color-scheme: light)').matches?'light':'dark'}
function syncThemeIcon(){$('theme-toggle').textContent=currentTheme()==='light'?'☀':'☾'}
function toggleTheme(){const next=currentTheme()==='light'?'dark':'light';document.documentElement.dataset.theme=next;try{localStorage.setItem('fintick-theme',next)}catch(e){}syncThemeIcon()}
$('theme-toggle').addEventListener('click',toggleTheme);syncThemeIcon();
matchMedia('(prefers-color-scheme: light)').addEventListener('change',()=>{if(!document.documentElement.dataset.theme)syncThemeIcon()});
let refreshInFlight=false;async function refresh(){if(refreshInFlight)return;refreshInFlight=true;try{const response=await fetch('/api/feed',{cache:'no-store'});if(!response.ok)throw new Error('HTTP '+response.status);render(await response.json())}catch(error){$('connection').textContent='RECONNECTING';$('pulse').classList.add('error');if(!$('feed').querySelector('.card'))$('feed').replaceChildren(element('div','empty error-message','Event feed unavailable. FinTick will retry automatically.'))}finally{refreshInFlight=false}}
refresh(); setInterval(refresh, 20000);
let refreshInFlight=false;async function refresh(){if(refreshInFlight)return;refreshInFlight=true;try{const response=await fetch('/api/feed',{cache:'no-store'});if(!response.ok)throw new Error('HTTP '+response.status);render(await response.json())}catch(error){$('connection').textContent='RECONNECTING';$('pulse').classList.add('error');if(!$('feed').querySelector('.card'))setFeed([element('div','empty error-message','Event feed unavailable. FinTick will retry automatically.')])}finally{refreshInFlight=false}}
let masonryTimer=null;function scheduleMasonry(){if(masonryTimer)clearTimeout(masonryTimer);masonryTimer=setTimeout(layoutMasonry,120)}
addEventListener('resize',scheduleMasonry);if(document.fonts&&document.fonts.ready)document.fonts.ready.then(layoutMasonry);
layoutMasonry(); refresh(); setInterval(refresh, 20000);
</script>
</body>
</html>'''
Expand Down