diff --git a/fintick/dashboard.py b/fintick/dashboard.py index 3eff9d1..fd4bb7a 100644 --- a/fintick/dashboard.py +++ b/fintick/dashboard.py @@ -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} @@ -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')} @@ -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); '''