# Alerts

> Price alerts that fire once per crossing and stay lit until someone acknowledges them, with createAlerts from bo-grid/realtime.

An alert on a trading screen must not be missed. A price that touches its level and comes back has still crossed it, so a highlight that follows the current price is not enough. `createAlerts` from `bo-grid/realtime` keeps that state: named rules on a row fire when they turn true, and the row stays lit until someone acknowledges it, wherever the price goes next.

Each row below has a level above and below its price. When the last price crosses one, the row lights up and stays lit. Acknowledge one row with A, or all of them with the button.

Example: Alerts that stay lit (alerts.svelte)

```svelte
<script lang="ts">
  import { Grid, type ColumnDef, type GridApi, type GridRow } from 'bo-grid';
  import { createAlerts, createTickStream } from 'bo-grid/realtime';
  import { quotes, type Quote } from './data';
  import { simulateFeed, type Tick } from './feed';

  type Row = Quote & { above: number; below: number };
  const rows = $state.raw<Row[]>(quotes().slice(0, 8).map((q) => ({ ...q, above: Math.round(q.price * 1.004 * 100) / 100, below: Math.round(q.price * 0.996 * 100) / 100 })));

  // A rule fires when it turns true; the row stays lit until acknowledged.
  let version = $state(0);
  const alerts = createAlerts<Row, string | number>({
    key: (r) => r.id,
    rules: { above: (r) => r.price >= r.above, below: (r) => r.price <= r.below },
    onChange: () => version++,
  });
  const rowClass = $derived.by(() => {
    void version; // a new function when an alert fires or is acknowledged
    return (r: GridRow) => (alerts.isLit(r as Row) ? 'alert-lit' : undefined);
  });
  const active = $derived.by(() => {
    void version;
    return alerts.active(); // newest first
  });

  const columns: ColumnDef[] = [
    { type: 'text', key: 'symbol', header: 'Symbol', width: 90 },
    { type: 'price', key: 'price', header: 'Last', flash: 'auto' },
    { type: 'price', key: 'below', header: 'Alert below' },
    { type: 'price', key: 'above', header: 'Alert above', flex: 1 },
  ];

  let api: GridApi | undefined;
  const stream = createTickStream<number, Tick>({
    apply: (batch) => {
      api?.patchRows(batch);
      alerts.check(rows); // the rows hold the new prices now
    },
  });
  $effect(() => {
    stream.start();
    const stop = simulateFeed(rows, (id, t) => stream.push(id, t), 12);
    return () => {
      stop();
      stream.stop();
    };
  });
</script>

<div class="bar">
  <span>{active.length} lit{active.length ? ':' : ''} {active.slice(0, 4).map((a) => `${a.row.symbol} ${a.rule}`).join(', ')}</span>
  <button onclick={() => alerts.ackAll()} disabled={!active.length}>Acknowledge all</button>
  <span class="hint">or focus a row and press A</span>
</div>
<div class="watch">
  <Grid {rows} {columns} {rowClass} height={8 * 36} hotkeys={{ a: ({ row }) => void (row && alerts.ack(row.id)) }} onReady={(a) => (api = a)} />
</div>

<style>
  .bar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 6px 12px;
    margin: 0 0 8px;
    font-size: 12px;
  }
  button {
    font: inherit;
  }
  .hint {
    opacity: 0.6;
  }
  .watch :global(.alert-lit) {
    box-shadow: inset 3px 0 0 var(--bo-amber);
    background: color-mix(in srgb, var(--bo-amber) 14%, transparent);
  }
</style>
```

data.ts:

```ts
// Small, fixed data for the docs' examples: every example reads the same
// symbols, so a reader compares features, not datasets.
// A type, not an interface: it then fits GridRow's index signature.
export type Quote = {
  id: number;
  symbol: string;
  name: string;
  sector: string;
  price: number;
  change: number;
  volume: number;
};

const BASE: Omit<Quote, 'id'>[] = [
  { symbol: 'VNM', name: 'Vinamilk', sector: 'Consumer', price: 68.5, change: 1.2, volume: 1_204_000 },
  { symbol: 'FPT', name: 'FPT Corp', sector: 'Technology', price: 132, change: -0.4, volume: 860_500 },
  { symbol: 'HPG', name: 'Hoa Phat', sector: 'Materials', price: 27.35, change: 0.74, volume: 3_412_900 },
  { symbol: 'VCB', name: 'Vietcombank', sector: 'Financials', price: 91.5, change: 0.55, volume: 1_986_200 },
  { symbol: 'MWG', name: 'Mobile World', sector: 'Consumer', price: 51.4, change: -1.1, volume: 2_250_400 },
  { symbol: 'SSI', name: 'SSI Securities', sector: 'Financials', price: 33.3, change: -0.45, volume: 4_104_700 },
  { symbol: 'VIC', name: 'Vingroup', sector: 'Real estate', price: 44.15, change: -0.11, volume: 1_530_100 },
  { symbol: 'GAS', name: 'PV Gas', sector: 'Energy', price: 71.8, change: 0.3, volume: 645_800 },
  { symbol: 'MSN', name: 'Masan', sector: 'Consumer', price: 76.2, change: 2.05, volume: 1_120_300 },
  { symbol: 'TCB', name: 'Techcombank', sector: 'Financials', price: 23.9, change: 0.84, volume: 5_870_000 },
  { symbol: 'VHM', name: 'Vinhomes', sector: 'Real estate', price: 41.6, change: -0.72, volume: 2_904_600 },
  { symbol: 'POW', name: 'PV Power', sector: 'Energy', price: 12.25, change: 1.66, volume: 6_315_200 },
];

/** A fresh copy of the quotes, so one example's edits never reach another. */
export function quotes(): Quote[] {
  return BASE.map((q, i) => ({ id: i + 1, ...q }));
}
```

feed.ts:

```ts
// A simulated market feed for the docs' examples: a random walk on each
// quote's price. In an app, this is your socket's message handler.
import type { Quote } from './data';

export type Tick = { price: number; change: number; volume: number };

/** Calls `onTick` about `perSecond` times a second (a number, or a function
    read on every beat, so the rate can change while it runs), for a random
    row each time. Returns a function that stops the feed. */
export function simulateFeed(rows: readonly Quote[], onTick: (id: number, tick: Tick) => void, perSecond: number | (() => number) = 20): () => void {
  const rate = typeof perSecond === 'function' ? perSecond : () => perSecond;
  const state = rows.map((r) => ({ id: r.id, ref: r.price / (1 + r.change / 100), price: r.price, volume: r.volume }));
  const every = 50;
  let due = 0;
  const timer = setInterval(() => {
    for (due += (rate() * every) / 1000; due >= 1; due--) {
      const s = state[Math.floor(Math.random() * state.length)];
      s.price = Math.max(0.01, Math.round(s.price * (1 + (Math.random() - 0.5) * 0.006) * 100) / 100);
      s.volume += Math.ceil(Math.random() * 40) * 100;
      onTick(s.id, { price: s.price, change: Math.round(((s.price - s.ref) / s.ref) * 10_000) / 100, volume: s.volume });
    }
  }, every);
  return () => clearInterval(timer);
}
```

## Creating alerts

```ts
import { createAlerts } from 'bo-grid/realtime';

// Quote is your row type: { id, symbol, last, alertAbove?, alertBelow?, … }
let version = $state(0);
const alerts = createAlerts<Quote, string>({
  key: (q) => q.symbol,
  rules: {
    above: (q) => q.alertAbove !== undefined && q.last >= q.alertAbove,
    below: (q) => q.alertBelow !== undefined && q.last <= q.alertBelow,
  },
  onChange: () => version++, // an alert fired, or was acknowledged
});

// On each update, with all the quotes or only the ones that changed:
for (const a of alerts.check(quotes)) chime(a.key, a.rule);
```

`check` returns the alerts that fired just now, each `{ key, rule, row, at }`: play a sound, or show a toast. With plain rows patched through `api.patchRows`, call it right after the patch, when the rows hold the new values.

## Lighting the rows

The lit style is yours, through `rowClass` (or `cellClass` on the price column). Give the grid a new function whenever the lit alerts change, so the rows restyle even when no data arrives:

```svelte
<script lang="ts">
  const rowClass = $derived.by(() => {
    void version; // a new function when an alert fires or is acknowledged
    return (row: GridRow) => (alerts.isLit(row as Quote) ? 'alert-lit' : undefined);
  });
</script>

<Grid rows={quotes} {columns} {rowClass} hotkeys={{ a: ({ row }) => { if (row) alerts.ack(String(row.symbol)); } }} height={480} />
```

The grid hands callbacks a `GridRow`; the cast says it is one of your quotes.

`isLit(row, rule?)` asks about one rule or any; `lit(row)` lists a row's lit rules. A hotkey (see [Keyboard & hotkeys](https://bonguynvan.github.io/bo-grid/docs/keyboard/)) acknowledges the focused row.

## How alerts behave

- **Once per crossing.** A rule fires when it turns true. While it stays lit, the price can go back and forth without a second alert.
- **Acknowledging.** `ack(key, rule?)` darkens a row's alerts, or one rule's, and returns how many went dark; `ackAll()` darkens them all. A rule still holding when acknowledged waits until it stops holding before it can fire again, so a price sitting past its level does not light up again at once.
- **An alert panel.** `active()` lists the lit alerts, newest first, each with its row as last checked and when it fired. `size` counts them.
- **The first check** fires for a rule that is already true. With `fireOnFirst: false`, it waits for the rule to stop holding and turn true again.
- **A level that moves** is a rule that now reads differently: moved past the price, it fires. A lit alert stays lit until acknowledged, wherever the level goes; acknowledge on edit if that is what your screen wants.
- **Rows that leave.** `check(rows, { complete: true })` says the rows given are the whole list and forgets every key not among them, lit alerts included. `forget(key)` drops one key, and `clear()` everything. Keys are unique within one `check`.
- **Errors.** A rule that throws stops the check there, after counting the alerts that fired before it.

`onChange` runs whenever the lit alerts change (a fire, an acknowledgement, a forget), and `version` bumps with it.

The crypto watchlist in the FX and crypto example lights a row when its price reaches its level; A acknowledges it:

Live example: https://bonguynvan.github.io/bo-grid/examples/fxcrypto/ (source: https://github.com/bonguynvan/bo-grid/blob/main/src/demo/examples/)

## Reference

`createAlerts<R, K>(options)` returns an `Alerts<R, K>`.

| Option | Type | Default | What it does |
| --- | --- | --- | --- |
| `key` | `(row: R) => K` | required | A row's identity (its symbol). |
| `rules` | `Record<string, (row: R) => boolean>` | required | Named rules; each fires when it turns true. Read once, when the alerts are made. |
| `fireOnFirst` | `boolean` | `true` | Whether a rule already true at a row's first check fires. |
| `onChange` | `() => void` | none | Runs when the lit alerts change. |

| Member | What it does |
| --- | --- |
| `check(rows, { complete? })` | Checks rows and returns the alerts that fired now. |
| `isLit(row, rule?)` | Whether a row has a lit alert (for `rule`, or any). |
| `lit(row)` | A row's lit rules, in the order the rules were given. |
| `ack(key, rule?)` | Acknowledges a row's alerts; returns how many went dark. |
| `ackAll()` | Acknowledges every alert. |
| `active()` | The lit alerts, newest first. |
| `size`, `version` | Lit alerts; a counter that bumps on every change. |
| `forget(key)`, `clear()` | Drops one key, or everything. |

- `rowClass`: `(row: GridRow) => string | undefined` — Return extra CSS class(es) for a data row (e.g. to colour by value). Style them via `:global(.your-class)` since rows live inside the grid.

  ```svelte
  <Grid {rows} {columns} height={480} rowClass={(row) => (row.status === 'Rejected' ? 'rejected' : undefined)} />
  ```

- `hotkeys`: `Hotkeys` (default `undefined`) — Keys for the app, run before the grid's own. A binding is a key (`'b'`, `'Delete'`, `'Space'`; a symbol by the character it types: `'?'`, not `'Shift+/'`), after any of `Shift+`, `Alt+`, `Ctrl+`, `Meta+` and `Mod+` (Ctrl or ⌘, either). Case does not matter. The handler is given the focused row and column (null when there is none) and the event; return false to leave the key to the grid. Keys go to the grid, not to a binding, while a cell is edited or an input, button or menu inside the grid has them, and a key a binding took runs it once when held down. A binding that cannot be read, or binds a key twice, throws (BG1001–BG1004).

  ```svelte
  <Grid {rows} {columns} height={480}
    hotkeys={{ b: ({ row }) => { if (row) buy(row); }, 'Shift+C': cancelAll, Delete: ({ row }) => { if (row) cancel(row); } }} />
  ```

---
Page: https://bonguynvan.github.io/bo-grid/docs/alerts/ · All pages: https://bonguynvan.github.io/bo-grid/llms.txt
