wickchart-alerts-plus 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +49 -0
  2. package/alerts-plus.mjs +213 -0
  3. package/package.json +35 -0
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # wickchart-alerts-plus
2
+
3
+ The "pro" alert tier for [wickchart](https://github.com/benyblack/wickchart),
4
+ as an opt-in plugin — core alerts are runtime-only by design, so persistence,
5
+ desktop notifications and webhooks live here instead. Zero dependencies.
6
+
7
+ - **Persistence**: the chart's alert list is mirrored into `localStorage`
8
+ at save points (adds/removes, fires, `sync()`, `detach()`) and re-armed on
9
+ the next page load. Once-fired alerts drop out of storage automatically;
10
+ alerts added directly on the chart are picked up too.
11
+ - **Hidden-tab surfacing**: while the tab is hidden, a fired alert raises a
12
+ desktop notification (with the user's permission) and a short two-tone
13
+ WebAudio beep — no audio files, no dependencies.
14
+ - **Webhook**: an optional URL receives `POST { id, price, when, time, bar,
15
+ key }` on every fire, regardless of tab visibility. Fire-and-forget;
16
+ rejections are swallowed.
17
+
18
+ ```js
19
+ npm install wickchart wickchart-alerts-plus // the plugin is a separate package
20
+
21
+ import 'wickchart'; // the chart itself
22
+ import { attachAlertsPlus } from 'wickchart-alerts-plus';
23
+
24
+ const chart = document.querySelector('wick-chart');
25
+ const ap = attachAlertsPlus(chart, {
26
+ key: 'BTC:1h', // storage key — one per symbol+timeframe
27
+ notify: true, // desktop notification while hidden
28
+ sound: true, // beep while hidden
29
+ webhook: 'https://example.com/hook', // optional
30
+ });
31
+ await ap.requestNotify(); // ask for the notification permission once
32
+
33
+ ap.add({ price: 100, direction: 'above' }); // persisted, re-armed on reload
34
+ ap.add({ when: 'rsi(close,14) < 30' }); // scripted alerts persist too
35
+ ap.list(); // → serializable snapshots
36
+ ap.remove(id); ap.clear(); ap.sync(); ap.detach();
37
+ ```
38
+
39
+ Notes:
40
+
41
+ - `storage` and `fetch` are injectable (tests, SSR, custom backends);
42
+ without storage the plugin is memory-only and never throws — private
43
+ mode / quota failures are silently best-effort.
44
+ - Restored alerts re-arm (`fired` resets): a non-`once` alert that fired
45
+ before a reload can fire again after it.
46
+ - Everything on the chart keeps working: `addAlert` / `removeAlert` /
47
+ `clearAlerts` / `getState`, `wick:alert` events, `once` semantics.
48
+
49
+ Peer dependency: wickchart ≥ 1.4.0.
@@ -0,0 +1,213 @@
1
+ /**
2
+ * wickchart-alerts-plus — the "pro" alert tier as a wickchart plugin:
3
+ * core alerts are runtime-only by design, so this adds persistence
4
+ * (localStorage), desktop notifications + a beep while the tab is hidden,
5
+ * and an optional webhook — without the core growing any of it.
6
+ *
7
+ * import { attachAlertsPlus } from 'wickchart-alerts-plus';
8
+ * const ap = attachAlertsPlus(chart, {
9
+ * key: 'BTC:1h', // storage key (default 'wickchart-alerts')
10
+ * notify: true, // desktop notification when the tab is hidden
11
+ * sound: true, // short beep when the tab is hidden
12
+ * webhook: 'https://…', // POST { id, price, when, bar, time } on fire
13
+ * });
14
+ * ap.add({ price: 100, direction: 'above' }); // → persisted, re-armed
15
+ * ap.add({ when: 'rsi(close,14) < 30' }); // scripted alerts too
16
+ * ap.remove(id); ap.clear(); ap.list(); ap.sync(); ap.detach();
17
+ * ap.requestNotify(); // ask for the desktop-notification permission
18
+ *
19
+ * Everything the chart already does still works: addAlert/removeAlert/
20
+ * clearAlerts/getState, wick:alert events, once-semantics. The plugin
21
+ * mirrors the chart's live alert list into storage at save points (plugin
22
+ * ops, fires, detach, sync()) — so alerts added directly on the chart get
23
+ * persisted too, and once-fired ones drop out of storage automatically.
24
+ * Storage is injectable and optional: without it the plugin is in-memory.
25
+ */
26
+
27
+ const MAX_PERSISTED = 50;
28
+
29
+ const isNum = (v) => typeof v === 'number' && Number.isFinite(v);
30
+
31
+ /** Serializable snapshot of one chart alert (drops compiled/fired). */
32
+ function snapshot(a) {
33
+ const out = { id: String(a.id), once: a.once !== false };
34
+ if (a.when != null) out.when = String(a.when);
35
+ if (isNum(a.price)) {
36
+ out.price = a.price;
37
+ out.direction = a.direction || 'cross';
38
+ }
39
+ return out;
40
+ }
41
+
42
+ export function attachAlertsPlus(chart, opts = {}) {
43
+ return new AlertsPlus(chart, opts);
44
+ }
45
+
46
+ export class AlertsPlus {
47
+ constructor(chart, opts = {}) {
48
+ if (!chart || typeof chart.addAlert !== 'function' || typeof chart.addEventListener !== 'function') {
49
+ throw new TypeError('attachAlertsPlus(chart): the chart element is required');
50
+ }
51
+ this._chart = chart;
52
+ this._storage = opts.storage != null ? opts.storage : globalThis.localStorage;
53
+ this._key = typeof opts.key === 'string' && opts.key ? opts.key : 'wickchart-alerts';
54
+ this._notify = opts.notify !== false;
55
+ this._sound = opts.sound !== false;
56
+ this._webhook = typeof opts.webhook === 'string' && opts.webhook ? opts.webhook : null;
57
+ this._fetch = typeof opts.fetch === 'function' ? opts.fetch : null;
58
+ this._audio = null;
59
+ this._onAlert = (e) => this._fire(e.detail);
60
+ chart.addEventListener('wick:alert', this._onAlert);
61
+ this._restore();
62
+ }
63
+
64
+ /* ---------------- public API ---------------- */
65
+
66
+ /** Add an alert through the chart and persist it. Returns the id or null. */
67
+ add(alert) {
68
+ const id = this._chart.addAlert(alert);
69
+ if (id != null) this.sync();
70
+ return id;
71
+ }
72
+
73
+ remove(id) {
74
+ this._chart.removeAlert(id);
75
+ this.sync();
76
+ }
77
+
78
+ clear() {
79
+ this._chart.clearAlerts();
80
+ this.sync();
81
+ }
82
+
83
+ /** Live alert list from the chart (read-only view). */
84
+ list() {
85
+ const st = this._chart.getState();
86
+ return (st && Array.isArray(st.alerts) ? st.alerts : []).map((a) => snapshot(a));
87
+ }
88
+
89
+ /** Snapshot the chart's current alerts into storage (all of them). */
90
+ sync() {
91
+ this._save(this.list());
92
+ return this;
93
+ }
94
+
95
+ /** Ask the user for the desktop-notification permission (no-op elsewhere). */
96
+ async requestNotify() {
97
+ try {
98
+ if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
99
+ return await Notification.requestPermission();
100
+ }
101
+ return typeof Notification !== 'undefined' ? Notification.permission : 'denied';
102
+ } catch (_) {
103
+ return 'denied';
104
+ }
105
+ }
106
+
107
+ detach() {
108
+ this._chart.removeEventListener('wick:alert', this._onAlert);
109
+ this.sync(); // flush whatever the session changed
110
+ this._chart = null;
111
+ }
112
+
113
+ /* ---------------- internals ---------------- */
114
+
115
+ _read() {
116
+ if (!this._storage) return [];
117
+ try {
118
+ const raw = this._storage.getItem(this._key);
119
+ const list = raw ? JSON.parse(raw) : [];
120
+ return Array.isArray(list) ? list.filter((a) => a && a.id != null).slice(0, MAX_PERSISTED) : [];
121
+ } catch (_) {
122
+ return [];
123
+ }
124
+ }
125
+
126
+ _save(list) {
127
+ if (!this._storage) return;
128
+ try {
129
+ this._storage.setItem(this._key, JSON.stringify(list.slice(0, MAX_PERSISTED)));
130
+ } catch (_) {
131
+ /* private mode / quota — persistence is best-effort */
132
+ }
133
+ }
134
+
135
+ /** Re-arm every persisted alert on the chart (invalid entries dropped). */
136
+ _restore() {
137
+ for (const a of this._read()) {
138
+ this._chart.addAlert(a);
139
+ }
140
+ }
141
+
142
+ /**
143
+ * A chart alert fired: persist (once-fired alerts drop out via the chart),
144
+ * and — while the tab is hidden — raise a desktop notification and a beep.
145
+ * The webhook fires regardless of visibility.
146
+ */
147
+ _fire(detail) {
148
+ if (!this._chart) return;
149
+ const hidden = typeof document !== 'undefined' && document.hidden === true;
150
+ if (hidden && this._notify) this._notifyUser(detail);
151
+ if (hidden && this._sound) this._beep();
152
+ if (this._webhook) this._post(detail);
153
+ // the core removes once-fired alerts right after dispatching this event;
154
+ // save on a microtask so storage reflects the post-fire state
155
+ queueMicrotask(() => {
156
+ if (this._chart) this.sync();
157
+ });
158
+ }
159
+
160
+ _notifyUser(detail) {
161
+ try {
162
+ if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return;
163
+ const label =
164
+ detail && detail.when != null
165
+ ? detail.when
166
+ : `price ${detail && isNum(detail.price) ? detail.price : '?'}`;
167
+ new Notification('WickChart alert', { body: `Alert fired: ${label}`, tag: detail && detail.id });
168
+ } catch (_) {}
169
+ }
170
+
171
+ /** Short two-tone beep via WebAudio (created lazily; never throws). */
172
+ _beep() {
173
+ try {
174
+ const AC = globalThis.AudioContext || globalThis.webkitAudioContext;
175
+ if (!AC) return;
176
+ if (!this._audio) this._audio = new AC();
177
+ const t = this._audio.currentTime;
178
+ const gain = this._audio.createGain();
179
+ gain.gain.setValueAtTime(0.001, t);
180
+ gain.gain.exponentialRampToValueAtTime(0.2, t + 0.02);
181
+ gain.gain.exponentialRampToValueAtTime(0.001, t + 0.25);
182
+ gain.connect(this._audio.destination);
183
+ for (const [f, dt] of [[880, 0], [660, 0.12]]) {
184
+ const osc = this._audio.createOscillator();
185
+ osc.type = 'sine';
186
+ osc.frequency.setValueAtTime(f, t + dt);
187
+ osc.connect(gain);
188
+ osc.start(t + dt);
189
+ osc.stop(t + dt + 0.12);
190
+ }
191
+ } catch (_) {}
192
+ }
193
+
194
+ /** Fire-and-forget webhook POST; rejection is swallowed. */
195
+ _post(detail) {
196
+ const doFetch = this._fetch || globalThis.fetch;
197
+ if (typeof doFetch !== 'function') return;
198
+ try {
199
+ doFetch(this._webhook, {
200
+ method: 'POST',
201
+ headers: { 'content-type': 'application/json' },
202
+ body: JSON.stringify({
203
+ id: detail && detail.id,
204
+ price: detail && detail.price,
205
+ when: detail && detail.when,
206
+ time: detail && detail.bar ? detail.bar.time : null,
207
+ bar: detail && detail.bar,
208
+ key: this._key,
209
+ }),
210
+ }).catch(() => {});
211
+ } catch (_) {}
212
+ }
213
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "wickchart-alerts-plus",
3
+ "version": "0.1.0",
4
+ "description": "Persistent alerts for wickchart — survives reloads via localStorage, desktop notifications + sound while the tab is hidden, optional webhook. Opt-in plugin, zero dependencies.",
5
+ "type": "module",
6
+ "main": "alerts-plus.mjs",
7
+ "module": "alerts-plus.mjs",
8
+ "exports": {
9
+ ".": "./alerts-plus.mjs"
10
+ },
11
+ "files": [
12
+ "alerts-plus.mjs",
13
+ "README.md"
14
+ ],
15
+ "keywords": [
16
+ "chart",
17
+ "charting",
18
+ "alerts",
19
+ "notifications",
20
+ "webhook",
21
+ "persistence",
22
+ "trading",
23
+ "web-component",
24
+ "wickchart"
25
+ ],
26
+ "license": "MIT",
27
+ "peerDependencies": {
28
+ "wickchart": ">=1.4.0"
29
+ },
30
+ "peerDependenciesMeta": {
31
+ "wickchart": {
32
+ "optional": true
33
+ }
34
+ }
35
+ }