RateAlerts
Set an alert on an exchange rate, a pair above or below a rate, and see the alerts set, how far each is, and remove them.
Demo
No change yet.
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/rate_alerts.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/rate_alerts.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/rate_alerts.qwe
let rate_alerts_demo_rates = { "EUR/USD": 1.08425m, "GBP/USD": 1.27539m, "USD/JPY": 156.2445m, "USD/PHP": 58.1225m }component RateAlertsDemoLive() { state note = "No change yet." <div.demo-stack> <RateAlerts rates={rate_alerts_demo_rates} alerts={[{ id: "a1", pair: "EUR/USD", direction: "above", rate: 1.09m }, { id: "a2", pair: "USD/JPY", direction: "below", rate: 157.5m }]} on_add={(a) => { note = `Added ${a.id}: ${a.pair} ${a.direction} ${a.rate}` }} on_remove={(a) => { note = `Removed ${a.id}` }}/> <p.demo-note id="rate-alerts-demo-note">{note}</p> </div>}view RateAlertsDemo() = <div.demo-stack> <RateAlertsDemoLive/> <RateAlerts name="desk" label="Branch alerts" pairs={["USD/PHP", "EUR/PHP"]} action="#alerts" most={3}/> </div>Source
ui/rate_alerts.qwe
// RateAlerts: set an alert on an exchange rate: the pair, above or below, and the// rate; then the alerts set so far, each with how far the rate is from it (in pips)// or that it has been reached, and a button to remove it. A rate that is not a// number, or an alert set twice, is refused; one the rate has already passed is// set, with a warning. The fields are real (`pair`, `direction`, `rate`): with// `action` and no on_add, Add posts them, and each Remove posts `remove` with the// alert's id, so the server can keep the list.//// <RateAlerts rates={{ "EUR/USD": 1.08425m, "USD/JPY": 156.24m }} alerts={saved}// on_add={(a) => save_alert(a)} on_remove={(a) => drop_alert(a.id)}/>// <RateAlerts pairs={["EUR/USD", "GBP/USD"]} action="/alerts"/>//// rates: a map of pair to its rate now ({ "EUR/USD": 1.08425m }), for the distance and// the warning (decimals best; numbers are read as decimals). pairs: the pairs to offer,// in order (the rates' pairs). alerts: those set already, a list of { id, pair,// direction, rate }, direction "above" or "below". most: how many can be set (10).// label: the heading ("Rate alerts"). action: where the form posts, when there is no// on_add (none keeps the list in the page). name: put before the fields' names ("fx"// makes fx_pair, ...) and their ids. on_add(alert) and on_remove(alert): inside one of// your components, given { id, pair, direction, rate } (rate a decimal) as alerts are// added and removed.import ui/theme.qwe use ui_clslet rate_alerts_css = css`.ui-fxalert { display: grid; gap: 14px; min-width: 0; max-width: 520px; margin: 0; padding: 18px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); font: 14px/1.4 var(--ui-font); color: var(--ui-fg); }.ui-fxalert .ui-fxalert-title { margin: 0; font-size: 15px; font-weight: 650; }.ui-fxalert-form { display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr) minmax(0, 1fr) auto; align-items: end; gap: 8px; }.ui-fxalert-field { display: grid; gap: 6px; min-width: 0; }.ui-fxalert-label { font-size: 13px; font-weight: 500; color: var(--ui-muted); }.ui-fxalert-control { box-sizing: border-box; width: 100%; height: 38px; margin: 0; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 14px var(--ui-font); font-variant-numeric: tabular-nums; transition: border-color .15s, box-shadow .15s; }.ui-fxalert-control:hover { border-color: var(--ui-strong); }.ui-fxalert-control:focus { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-fxalert-control[aria-invalid="true"] { border-color: var(--ui-danger); }.ui-fxalert-add { height: 38px; padding: 0 16px; border: 0; border-radius: var(--ui-radius-sm); background: var(--ui-accent); color: var(--ui-accent-fg); font: 600 14px var(--ui-font); cursor: pointer; }.ui-fxalert-add:hover:not(:disabled) { filter: brightness(1.08); }.ui-fxalert-add:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-fxalert-add:disabled { opacity: .45; cursor: not-allowed; }.ui-fxalert-now { margin: -6px 0 0; color: var(--ui-muted); font-size: 12px; font-variant-numeric: tabular-nums; }.ui-fxalert-msg { margin: -6px 0 0; font-size: 13px; }.ui-fxalert-error { color: var(--ui-danger); }.ui-fxalert-warn { color: var(--ui-warning); }.ui-fxalert-list { display: grid; margin: 0; padding: 0; list-style: none; border-top: 1px solid var(--ui-border); }.ui-fxalert-item { display: grid; grid-template-columns: auto 1fr auto auto; align-items: center; gap: 10px; padding: 10px 0; border-bottom: 1px solid color-mix(in srgb, var(--ui-border) 60%, transparent); font-variant-numeric: tabular-nums; }.ui-fxalert-dir { display: grid; place-items: center; width: 26px; height: 26px; border-radius: 50%; font-size: 14px; font-weight: 700; }.ui-fxalert-above .ui-fxalert-dir { color: var(--ui-success); background: var(--ui-success-soft); }.ui-fxalert-below .ui-fxalert-dir { color: var(--ui-danger); background: var(--ui-danger-soft); }.ui-fxalert-what b { font-weight: 650; }.ui-fxalert-what small { display: block; color: var(--ui-muted); font-size: 12px; }.ui-fxalert-state { padding: 2px 8px; border-radius: 999px; background: var(--ui-subtle); color: var(--ui-muted); font-size: 12px; white-space: nowrap; }.ui-fxalert-hit .ui-fxalert-state { background: var(--ui-warning-soft); color: var(--ui-fg); font-weight: 600; }.ui-fxalert-remove { display: grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 0; border-radius: var(--ui-radius-sm); background: transparent; color: var(--ui-muted); cursor: pointer; }.ui-fxalert-remove svg { width: 14px; height: 14px; }.ui-fxalert-remove:hover { background: var(--ui-danger-soft); color: var(--ui-danger); }.ui-fxalert-remove:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-fxalert-empty { margin: 0; padding: 14px 0 2px; border-top: 1px solid var(--ui-border); color: var(--ui-muted); font-size: 13px; }@media (max-width: 520px) { .ui-fxalert-form { grid-template-columns: 1fr 1fr; } }@media (prefers-reduced-motion: reduce) { .ui-fxalert-control { transition: none; } }`// A pip's size for "EUR/USD": 0.01 when the quote is priced in the hundreds, else 0.0001.fn rate_alerts_pip(pair: str) -> dec { let parts = split(upper(pair), "/") contains(["JPY", "HUF", "KRW", "IDR", "VND", "CLP", "ISK", "THB", "INR", "PHP", "RUB"], len(parts) > 1 ? parts[1] : "") ? 0.01m : 0.0001m}// A rate written with a pip's decimals and one more: 1.09000, 157.500.fn rate_alerts_show(pair: str, r: dec) -> str { let places = rate_alerts_pip(pair) == 0.01m ? 3 : 5 format_number(round(r, places, "half_up"), "en-US", places)}// What was typed as a rate above 0 ("1.09" is 1.09m), or null.fn rate_alerts_read(text: str) { let t = re.replace(trim(text), "[,\\s_]", "") if t == "" { return null } let d = dec(t) is_err(d) || d <= 0 ? null : d}// Whether an alert has been reached by the rate now (false when that is not known).fn rate_alerts_hit(a, now) -> bool = now != null && (a.direction == "below" ? dec(now) <= dec(a.rate) : dec(now) >= dec(a.rate))component RateAlerts(rates: map = {}, pairs: list? = null, alerts: list = [], most: int = 10, label: str = "Rate alerts", action: str? = null, name: str? = null, on_add = null, on_remove = null, class: str? = null) { style global rate_alerts_css let offered = pairs ?? keys(rates) let field = (f: str) => name != null ? `${name}_${f}` : f let id = `rate-alerts-${name ?? "fx"}` let local = action == null || on_add != null state saved = alerts | map((a) => { let r = dec(a.rate) { id: str(a.id), pair: a.pair, direction: a.direction, rate: r } }) state pair = len(offered) > 0 ? offered[0] : "" state dir = "above" state text = "" state tried = false state said = "" state next = len(alerts) + 1 let value = rate_alerts_read(text) let now = rates[pair] let dupe = value != null && any(saved, (a) => a.pair == pair && a.direction == dir && a.rate == value) let full = len(saved) >= most let problem = value == null ? (text == "" ? "Enter the rate to be told at." : `“${text}” is not a rate.`) : (dupe ? `There is already an alert for ${pair} ${dir} ${rate_alerts_show(pair, value)}.` : (full ? `You can set ${most} alerts at most; remove one first.` : null)) let passed = value != null && now != null && (dir == "above" ? dec(now) >= value : dec(now) <= value) let pid = (a) => `${id}-${a.id}` <form class={ui_cls(["ui-fxalert", class])} method="post" action={action} aria-labelledby={`${id}-title`} novalidate> <h3.ui-fxalert-title id={`${id}-title`}>{label}</h3> <div.ui-fxalert-form> <div.ui-fxalert-field> <label.ui-fxalert-label for={`${id}-pair`}>Pair</label> <select.ui-fxalert-control id={`${id}-pair`} name={field("pair")} @change={(e) => { pair = e.value }}> {for p in offered => <option key={p} value={p} selected={p == pair}>{p}</option>} </select> </div> <div.ui-fxalert-field> <label.ui-fxalert-label for={`${id}-direction`}>When it goes</label> <select.ui-fxalert-control id={`${id}-direction`} name={field("direction")} @change={(e) => { dir = e.value }}> <option value="above" selected={dir == "above"}>Above</option> <option value="below" selected={dir == "below"}>Below</option> </select> </div> <div.ui-fxalert-field> <label.ui-fxalert-label for={`${id}-rate`}>Rate</label> <input.ui-fxalert-control type="text" id={`${id}-rate`} name={field("rate")} value={text} inputmode="decimal" autocomplete="off" placeholder={now != null ? rate_alerts_show(pair, dec(now)) : null} aria-invalid={tried && problem != null ? "true" : null} aria-describedby={`${id}-msg`} @input={(e) => { text = e.value tried = false }}/> </div> <button.ui-fxalert-add type="submit" disabled={full} @click={(e) => { if local { e.preventDefault() tried = true let v = rate_alerts_read(text) if problem == null && v != null { let a = { id: `a${next}`, pair, direction: dir, rate: v } next += 1 saved = saved + [a] text = "" tried = false said = `Alert set: ${pair} ${dir} ${rate_alerts_show(pair, v)}.` on_add && on_add(a) } } }}>Add</button> </div> {now != null && <p.ui-fxalert-now>{pair} is at {rate_alerts_show(pair, dec(now))} now.</p>} <p id={`${id}-msg`} class={ui_cls(["ui-fxalert-msg", tried && problem != null && "ui-fxalert-error", !(tried && problem != null) && passed && "ui-fxalert-warn"])} role={tried && problem != null ? "alert" : null}> {tried && problem != null ? problem : (passed ? `${pair} is already ${dir} ${rate_alerts_show(pair, value)}: the alert would go at once.` : "")} </p> {len(saved) == 0 ? <p.ui-fxalert-empty>No alerts yet.</p> : <ul.ui-fxalert-list aria-label="Alerts set"> {for a in saved { let cur = rates[a.pair] let hit = rate_alerts_hit(a, cur) let away = cur != null ? abs(dec(a.rate) - dec(cur)) / rate_alerts_pip(a.pair) : null <li key={a.id} id={pid(a)} data-id={a.id} class={ui_cls(["ui-fxalert-item", `ui-fxalert-${a.direction}`, hit && "ui-fxalert-hit"])}> <span.ui-fxalert-dir aria-hidden="true">{a.direction == "below" ? "↓" : "↑"}</span> <span.ui-fxalert-what><b>{a.pair}</b> {a.direction} {rate_alerts_show(a.pair, dec(a.rate))} {cur != null && <small>now {rate_alerts_show(a.pair, dec(cur))}</small>}</span> <span.ui-fxalert-state>{hit ? "Reached" : (away != null ? `${format_number(round(away, 1, "half_up"), "en-US", 1)} pips away` : "Waiting")}</span> <button.ui-fxalert-remove type="submit" name={field("remove")} value={a.id} aria-label={`Remove the alert for ${a.pair} ${a.direction} ${rate_alerts_show(a.pair, dec(a.rate))}`} @click={(e) => { if local { e.preventDefault() saved = saved | filter((x) => x.id != a.id) said = `Alert removed: ${a.pair} ${a.direction} ${rate_alerts_show(a.pair, dec(a.rate))}.` on_remove && on_remove(a) } }}> <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M3 3l8 8M11 3l-8 8"/></svg> </button> </li> }} </ul>} <span.ui-sr aria-live="polite">{said}</span> </form>}Related
CurrencyConverterAn amount and two currencies with a swap button, the rate both ways and the converted amount, crossed through the base from the rates given.8 views · 11 downloadsFxQuoteTicketAn order ticket for a currency deal: buy or sell, the amount, the quoted rate counting down to its expiry, the other currency's amount and Confirm.8 views · 6 downloadsRemittanceQuoteSending money abroad: you send, the fee, the rate against the mid-market rate, what they get and when it arrives, worked out as the amount changes.8 views · 5 downloadsAccountPickerChoose the account to pay from as radio cards with each one's masked number and available balance, those short of the amount marked.7 views · 7 downloadsAssemblyNoticeA general assembly notice: date and time, the venue or online link, the quorum, a numbered agenda and an RSVP form.6 views · 6 downloadsBillListUpcoming bills soonest first, overdue and due-soon ones marked, autopay on or off, with the total to pay and a Pay button each.7 views · 6 downloads
Thread
Thread 0 posts
On RateAlerts: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.