LCOV - code coverage report
Current view: top level - dashql-app/src/demos/stock_analysis - live_board.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 99.1 % 106 105
Test Date: 2026-11-06 19:30:32

            Line data    Source code
       1              : import type * as arrow from 'apache-arrow';
       2              : import * as React from 'react';
       3              : 
       4              : import * as styles from './live_board.module.css';
       5              : 
       6              : /// A row of the live board, see live_board.sql
       7              : export interface BoardRow {
       8              :     symbol: string;
       9              :     last: number;
      10              :     open: number;
      11              :     change: number;
      12              :     changeRatio: number | null;
      13              :     bid: number | null;
      14              :     ask: number | null;
      15              :     volume: number;
      16              :     trades: number;
      17              :     updatedMs: number;
      18              : }
      19              : 
      20              : /// A number of a row, null for null
      21            1 : function numberOf(row: any, field: string): number | null {
      22            1 :     const value = row[field];
      23            1 :     if (value == null) return null;
      24            1 :     return Number(value);
      25            1 : }
      26              : 
      27              : /// The rows of the result of live_board.sql
      28            1 : export function readBoardRows(table: arrow.Table): BoardRow[] {
      29            1 :     const rows: BoardRow[] = [];
      30            1 :     for (const row of table) {
      31            1 :         if (row == null) continue;
      32            1 :         rows.push({
      33            1 :             symbol: String(row['symbol']),
      34            1 :             last: numberOf(row, 'last') ?? 0,
      35            1 :             open: numberOf(row, 'open') ?? 0,
      36            1 :             change: numberOf(row, 'change') ?? 0,
      37            1 :             changeRatio: numberOf(row, 'change_ratio'),
      38            1 :             bid: numberOf(row, 'bid'),
      39            1 :             ask: numberOf(row, 'ask'),
      40            1 :             volume: numberOf(row, 'volume') ?? 0,
      41            1 :             trades: numberOf(row, 'trades') ?? 0,
      42            1 :             updatedMs: numberOf(row, 'updated_ms') ?? 0,
      43            1 :         });
      44            1 :     }
      45            1 :     return rows;
      46            1 : }
      47              : 
      48              : /// The decimals of a price: cents for prices above a dollar, 4 significant digits below
      49            1 : export function formatPrice(value: number | null): string {
      50            1 :     if (value == null) return '–';
      51            1 :     if (Math.abs(value) >= 1 || value === 0) {
      52            1 :         return value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
      53              :     }
      54            1 :     return value.toLocaleString('en-US', { maximumSignificantDigits: 4 });
      55            1 : }
      56              : 
      57              : /// A change with its sign, the sign carries the direction next to the color
      58            1 : export function formatChange(value: number): string {
      59            1 :     const text = formatPrice(Math.abs(value));
      60            1 :     return value > 0 ? `+${text}` : value < 0 ? `−${text}` : text;
      61            1 : }
      62              : 
      63            1 : export function formatRatio(value: number | null): string {
      64            1 :     if (value == null) return '–';
      65            1 :     const text = `${(Math.abs(value) * 100).toFixed(2)}%`;
      66            1 :     return value > 0 ? `+${text}` : value < 0 ? `−${text}` : text;
      67            1 : }
      68              : 
      69            1 : const COMPACT = new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 });
      70              : 
      71              : /// The time since an update, e.g. 120ms, 4.2s, 3m
      72            1 : function formatAge(ms: number): string {
      73            1 :     if (ms < 0) ms = 0;
      74            1 :     if (ms < 1000) return `${Math.round(ms)}ms`;
      75            1 :     if (ms < 60 * 1000) return `${(ms / 1000).toFixed(1)}s`;
      76            1 :     if (ms < 60 * 60 * 1000) return `${Math.floor(ms / 60000)}m`;
      77            0 :     return `${Math.floor(ms / 3600000)}h`;
      78            1 : }
      79              : 
      80              : /// The direction class of a change
      81            1 : function directionClass(value: number | null): string | undefined {
      82            1 :     if (value == null || value === 0) return undefined;
      83            1 :     return value > 0 ? styles.up : styles.down;
      84            1 : }
      85              : 
      86              : interface Props {
      87              :     /// The rows of the board
      88              :     rows: readonly BoardRow[];
      89              :     /// The selected symbol
      90              :     selected: string | null;
      91              :     /// Select a symbol
      92              :     onSelect: (symbol: string) => void;
      93              :     /// The current time, for the age of the updates
      94              :     now: number;
      95              : }
      96              : 
      97              : /// The latest trade of every symbol. A price that changed flashes in the direction of the change.
      98            1 : export const LiveBoard: React.FC<Props> = (props: Props) => {
      99              :     // The previous price per symbol, for the direction of the flash
     100            1 :     const previous = React.useRef(new Map<string, number>());
     101              :     // The flashes belong to a result, the renders of the clock in between keep them
     102            1 :     const flashes = React.useMemo(() => {
     103            1 :         const flashes = new Map<string, number>();
     104            1 :         for (const row of props.rows) {
     105            1 :             const before = previous.current.get(row.symbol);
     106            1 :             if (before != null && before !== row.last) flashes.set(row.symbol, row.last - before);
     107            1 :         }
     108            1 :         return flashes;
     109            1 :     }, [props.rows]);
     110            1 :     React.useEffect(() => {
     111            1 :         previous.current = new Map(props.rows.map(row => [row.symbol, row.last]));
     112            1 :     }, [props.rows]);
     113              : 
     114            1 :     return (
     115            1 :         <table className={styles.board} data-testid="live-board">
     116            1 :             <thead>
     117            1 :                 <tr>
     118            1 :                     <th className={styles.symbol_col}>Symbol</th>
     119            1 :                     <th>Last</th>
     120            1 :                     <th>Change</th>
     121            1 :                     <th>Change %</th>
     122            1 :                     <th>Bid</th>
     123            1 :                     <th>Ask</th>
     124            1 :                     <th>Volume</th>
     125            1 :                     <th>Trades</th>
     126            1 :                     <th>Updated</th>
     127            1 :                 </tr>
     128            1 :             </thead>
     129            1 :             <tbody>
     130            1 :                 {props.rows.map(row => {
     131            1 :                     const flash = flashes.get(row.symbol);
     132            1 :                     return (
     133            1 :                         <tr
     134            1 :                             key={row.symbol}
     135            1 :                             className={row.symbol === props.selected ? styles.selected : undefined}
     136            1 :                             onClick={() => props.onSelect(row.symbol)}
     137            1 :                             aria-selected={row.symbol === props.selected}
     138              :                         >
     139            1 :                             <td className={styles.symbol_col}>{row.symbol}</td>
     140            1 :                             <td>
     141              :                                 {/* A new key restarts the animation of the flash */}
     142            1 :                                 <span key={row.last} className={flash == null ? undefined : flash > 0 ? styles.flash_up : styles.flash_down}>
     143            1 :                                     {formatPrice(row.last)}
     144            1 :                                 </span>
     145            1 :                             </td>
     146            1 :                             <td className={directionClass(row.change)}>{formatChange(row.change)}</td>
     147            1 :                             <td className={directionClass(row.changeRatio)}>{formatRatio(row.changeRatio)}</td>
     148            1 :                             <td>{formatPrice(row.bid)}</td>
     149            1 :                             <td>{formatPrice(row.ask)}</td>
     150            1 :                             <td>{COMPACT.format(row.volume)}</td>
     151            1 :                             <td>{COMPACT.format(row.trades)}</td>
     152            1 :                             <td className={styles.age}>{formatAge(props.now - row.updatedMs)}</td>
     153            1 :                         </tr>
     154              :                     );
     155            1 :                 })}
     156            1 :             </tbody>
     157            1 :         </table>
     158              :     );
     159            1 : };
        

Generated by: LCOV version 2.0-1