@sjvair/monitor-map 4.2.0 → 4.3.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 (29) hide show
  1. package/README.md +14 -0
  2. package/dist/lib/abort.d.ts +16 -0
  3. package/dist/lib/abort.js +51 -0
  4. package/dist/lib/collocation-sites/collocations.svelte.d.ts +3 -2
  5. package/dist/lib/collocation-sites/collocations.svelte.js +8 -4
  6. package/dist/lib/data-chart/DataChart.svelte +16 -1
  7. package/dist/lib/data-chart/requests.d.ts +2 -1
  8. package/dist/lib/data-chart/requests.js +5 -2
  9. package/dist/lib/ev-stations/ev-stations.svelte.d.ts +3 -2
  10. package/dist/lib/ev-stations/ev-stations.svelte.js +8 -6
  11. package/dist/lib/hms/hms.svelte.d.ts +4 -3
  12. package/dist/lib/hms/hms.svelte.js +16 -6
  13. package/dist/lib/monitors/components/MonitorSubscriptionManager.svelte +17 -22
  14. package/dist/lib/monitors/monitor-details.svelte.js +11 -3
  15. package/dist/lib/monitors/monitors.svelte.d.ts +14 -1
  16. package/dist/lib/monitors/monitors.svelte.js +119 -27
  17. package/dist/lib/monitors/subscriptions.d.ts +3 -0
  18. package/dist/lib/monitors/subscriptions.js +9 -0
  19. package/package.json +2 -2
  20. package/src/lib/abort.ts +59 -0
  21. package/src/lib/collocation-sites/collocations.svelte.ts +7 -4
  22. package/src/lib/data-chart/DataChart.svelte +16 -1
  23. package/src/lib/data-chart/requests.ts +14 -7
  24. package/src/lib/ev-stations/ev-stations.svelte.ts +11 -6
  25. package/src/lib/hms/hms.svelte.ts +12 -6
  26. package/src/lib/monitors/components/MonitorSubscriptionManager.svelte +17 -22
  27. package/src/lib/monitors/monitor-details.svelte.ts +12 -3
  28. package/src/lib/monitors/monitors.svelte.ts +142 -30
  29. package/src/lib/monitors/subscriptions.ts +17 -0
@@ -0,0 +1,9 @@
1
+ import { getSubscriptions } from "@sjvair/sdk/account";
2
+ export function getApiToken() {
3
+ const token = globalThis["USER"]?.api_token;
4
+ return typeof token === "string" && token !== "" ? token : undefined;
5
+ }
6
+ export async function loadSubscriptionLevel(monitorId, options) {
7
+ const subscriptions = await getSubscriptions(getApiToken(), { signal: options?.signal });
8
+ return subscriptions.find((s) => s.monitor === monitorId)?.level ?? null;
9
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sjvair/monitor-map",
3
3
  "description": "SJVAir's interactive monitor map",
4
- "version": "4.2.0",
4
+ "version": "4.3.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "https://github.com/SJVAir/monitor-map"
@@ -84,7 +84,7 @@
84
84
  "@lucide/svelte": "^1.46.0",
85
85
  "@maptiler/sdk": "^4.1.0",
86
86
  "@maptiler/weather": "^4.0.1",
87
- "@sjvair/sdk": "npm:@jsr/sjvair__sdk@^4.5.0",
87
+ "@sjvair/sdk": "npm:@jsr/sjvair__sdk@^4.7.0",
88
88
  "@tstk/builtin-extensions": "npm:@jsr/tstk__builtin-extensions@^1.0.8",
89
89
  "@tstk/utils": "npm:@jsr/tstk__utils@^1.1.2",
90
90
  "@turf/area": "^7.4.0",
@@ -0,0 +1,59 @@
1
+ export type SignalOptions = { signal?: AbortSignal };
2
+
3
+ /**
4
+ * A signal that aborts as soon as any input does, with that input's reason. Already aborted if
5
+ * any input is. Undefined inputs are ignored. Call `dispose()` to drop the listeners.
6
+ */
7
+ export function anySignal(...signals: Array<AbortSignal | undefined>): {
8
+ signal: AbortSignal;
9
+ dispose(): void;
10
+ } {
11
+ const controller = new AbortController();
12
+ const cleanups: Array<() => void> = [];
13
+ const dispose = () => {
14
+ for (const cleanup of cleanups.splice(0)) cleanup();
15
+ };
16
+
17
+ for (const input of signals) {
18
+ if (!input) continue;
19
+ if (input.aborted) {
20
+ controller.abort(input.reason);
21
+ dispose();
22
+ break;
23
+ }
24
+ const onAbort = () => {
25
+ controller.abort(input.reason);
26
+ dispose();
27
+ };
28
+ input.addEventListener("abort", onAbort, { once: true });
29
+ cleanups.push(() => input.removeEventListener("abort", onAbort));
30
+ }
31
+
32
+ return { signal: controller.signal, dispose };
33
+ }
34
+
35
+ /**
36
+ * Settles like `promise`, unless `signal` aborts first, in which case it rejects with
37
+ * `signal.reason`. The inner promise's later outcome is swallowed.
38
+ */
39
+ export function untilAborted<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
40
+ if (!signal) return promise;
41
+ if (signal.aborted) {
42
+ promise.catch(() => {});
43
+ return Promise.reject(signal.reason);
44
+ }
45
+ return new Promise<T>((resolve, reject) => {
46
+ const onAbort = () => reject(signal.reason);
47
+ signal.addEventListener("abort", onAbort, { once: true });
48
+ promise.then(
49
+ (value) => {
50
+ signal.removeEventListener("abort", onAbort);
51
+ resolve(value);
52
+ },
53
+ (error) => {
54
+ signal.removeEventListener("abort", onAbort);
55
+ reject(error);
56
+ }
57
+ );
58
+ });
59
+ }
@@ -1,22 +1,25 @@
1
1
  import { getCollocationSites, type CollocationSite } from "@sjvair/sdk";
2
+ import type { SignalOptions } from "$lib/abort";
2
3
 
3
4
  class CollocationSitesManager {
4
5
  initialized: boolean = $state(false);
5
6
 
6
7
  collocationSites: Array<CollocationSite> | null = $state(null);
7
8
 
8
- async init(): Promise<void> {
9
+ async init({ signal }: SignalOptions = {}): Promise<void> {
9
10
  if (this.initialized) return;
11
+ if (signal?.aborted) throw signal.reason;
10
12
 
11
- this.collocationSites = await getCollocationSites();
13
+ this.collocationSites = await getCollocationSites({ signal });
12
14
 
13
15
  this.initialized = true;
14
16
  }
15
17
 
16
- async update(): Promise<void> {
18
+ async update({ signal }: SignalOptions = {}): Promise<void> {
17
19
  if (!this.initialized) return;
20
+ if (signal?.aborted) throw signal.reason;
18
21
 
19
- this.collocationSites = await getCollocationSites();
22
+ this.collocationSites = await getCollocationSites({ signal });
20
23
  }
21
24
  }
22
25
 
@@ -143,12 +143,27 @@
143
143
  };
144
144
 
145
145
  let chartRequest = 0;
146
+ let chartController: AbortController | undefined;
147
+
148
+ // A closed panel cancels its in-flight fetch.
149
+ $effect(() => () => chartController?.abort());
146
150
 
147
151
  // `silent` keeps the current chart on screen while a timed refresh loads.
148
152
  async function loadChartData({ silent = false } = {}) {
149
153
  const request = ++chartRequest;
154
+ chartController?.abort();
155
+ const controller = (chartController = new AbortController());
150
156
  if (!silent) chartManager.loading = true;
151
- const data = await fetchChartData(monitor, chartManager.dateRange, pollutant);
157
+ let data: Awaited<ReturnType<typeof fetchChartData>>;
158
+ try {
159
+ data = await fetchChartData(monitor, chartManager.dateRange, pollutant, {
160
+ signal: controller.signal
161
+ });
162
+ } catch (err) {
163
+ // Superseded or closed: the newer call owns `loading` and the chart, so leave both.
164
+ if (controller.signal.aborted) return;
165
+ throw err;
166
+ }
152
167
  // A newer request (another range, monitor, pollutant or refresh) owns the chart now.
153
168
  if (request !== chartRequest) return;
154
169
  // A failed request resolves to [] (an empty range is [[], []]); a background refresh
@@ -2,6 +2,7 @@ import { getMonitorEntries, type MonitorDetails, type MonitorEntry } from "@sjva
2
2
  import { getUnixTime, parseISO, differenceInMinutes, addMinutes } from "date-fns";
3
3
  import type { PanelPollutant } from "../monitors/panel-pollutant";
4
4
  import type { DateRange } from "./DateRange";
5
+ import type { SignalOptions } from "$lib/abort";
5
6
 
6
7
  /** The monitor fields the chart reads. */
7
8
  export type ChartMonitor = Pick<MonitorDetails, "id" | "name" | "type" | "data_source">;
@@ -9,14 +10,18 @@ export type ChartMonitor = Pick<MonitorDetails, "id" | "name" | "type" | "data_s
9
10
  export async function fetchChartData(
10
11
  m: ChartMonitor,
11
12
  d: DateRange,
12
- entryType: PanelPollutant
13
+ entryType: PanelPollutant,
14
+ options: SignalOptions = {}
13
15
  ): Promise<uPlot.AlignedData> {
14
- return await getMonitorEntries({
15
- entryType,
16
- monitorId: m.id,
17
- timestampGte: d.start,
18
- timestampLte: d.end
19
- })
16
+ return await getMonitorEntries(
17
+ {
18
+ entryType,
19
+ monitorId: m.id,
20
+ timestampGte: d.start,
21
+ timestampLte: d.end
22
+ },
23
+ { signal: options.signal }
24
+ )
20
25
  .then((entries) => {
21
26
  const xAxisData: Array<Date> = [];
22
27
  const updateDuration = m.data_source.name === "PurpleAir" ? 2 : 60;
@@ -41,6 +46,8 @@ export async function fetchChartData(
41
46
  return [xAxisData.map((d) => getUnixTime(d)), yAxisRecord] as uPlot.AlignedData;
42
47
  })
43
48
  .catch((err) => {
49
+ // A cancelled request is not a failure: let the caller see the reason, log nothing.
50
+ if (options.signal?.aborted) throw options.signal.reason;
44
51
  console.error("Failed to load chart data: ", err);
45
52
  return [] as uPlot.AlignedData;
46
53
  });
@@ -5,6 +5,7 @@ import {
5
5
  evNetworkTypes,
6
6
  facilityTypes
7
7
  } from "./data-maps";
8
+ import type { SignalOptions } from "$lib/abort";
8
9
  import type { EvStation } from "./types";
9
10
  import * as ZipCodes from "./zip-codes";
10
11
 
@@ -12,12 +13,12 @@ class EvStationsManager {
12
13
  lvl2Stations: Array<EvStation> | undefined = $state();
13
14
  lvl3Stations: Array<EvStation> | undefined = $state();
14
15
 
15
- async loadLvl3Stations(): Promise<void> {
16
- this.lvl3Stations = await fetchEvStations("dc_fast");
16
+ async loadLvl3Stations({ signal }: SignalOptions = {}): Promise<void> {
17
+ this.lvl3Stations = await fetchEvStations("dc_fast", signal);
17
18
  }
18
19
 
19
- async loadLvl2Stations(): Promise<void> {
20
- this.lvl2Stations = await fetchEvStations("2");
20
+ async loadLvl2Stations({ signal }: SignalOptions = {}): Promise<void> {
21
+ this.lvl2Stations = await fetchEvStations("2", signal);
21
22
  }
22
23
  }
23
24
 
@@ -25,7 +26,11 @@ const zipCodes = Object.values(ZipCodes)
25
26
  .reduce((prev, curr) => prev.concat(curr))
26
27
  .join(",");
27
28
 
28
- async function fetchEvStations(chargingLevel: "2" | "dc_fast"): Promise<Array<EvStation>> {
29
+ async function fetchEvStations(
30
+ chargingLevel: "2" | "dc_fast",
31
+ signal?: AbortSignal
32
+ ): Promise<Array<EvStation>> {
33
+ if (signal?.aborted) throw signal.reason;
29
34
  const url = new URL(`https://developer.nlr.gov/api/alt-fuel-stations/v1.json`);
30
35
  url.searchParams.set("fuel_type", "ELEC");
31
36
  url.searchParams.set("state", "CA");
@@ -35,7 +40,7 @@ async function fetchEvStations(chargingLevel: "2" | "dc_fast"): Promise<Array<Ev
35
40
  url.searchParams.set("zip", zipCodes);
36
41
  url.searchParams.set("api_key", import.meta.env.VITE_NREL_KEY);
37
42
 
38
- return await fetch(url)
43
+ return await fetch(url, { signal })
39
44
  .then(async (res) => await res.json())
40
45
  .then((data) => {
41
46
  const fuelStations: Array<EvStation> = data.fuel_stations!;
@@ -4,6 +4,7 @@ import {
4
4
  type HMSFireGeoJSON,
5
5
  type HMSSmokeGeoJSON
6
6
  } from "@sjvair/sdk/hms";
7
+ import type { SignalOptions } from "$lib/abort";
7
8
  import clustersDbscan from "@turf/clusters-dbscan";
8
9
  import { featureCollection, point } from "@turf/helpers";
9
10
 
@@ -22,22 +23,27 @@ class HmsManager {
22
23
  fire: Array<HMSFireGeoJSON & { frp: number }> | undefined = $state();
23
24
  fireGroups: Array<HMSFireGroup> | undefined = $state();
24
25
 
25
- async init() {
26
- await Promise.allSettled([this.loadSmoke(), this.loadFire()]);
26
+ async init({ signal }: SignalOptions = {}) {
27
+ if (signal?.aborted) throw signal.reason;
28
+ await Promise.allSettled([this.loadSmoke({ signal }), this.loadFire({ signal })]);
29
+ if (signal?.aborted) throw signal.reason;
27
30
  }
28
31
 
29
- async loadSmoke() {
30
- this.smoke = await getHMSSmoke();
32
+ async loadSmoke({ signal }: SignalOptions = {}) {
33
+ if (signal?.aborted) throw signal.reason;
34
+ this.smoke = await getHMSSmoke({ signal });
31
35
  }
32
36
 
33
- async loadFire() {
37
+ async loadFire({ signal }: SignalOptions = {}) {
34
38
  try {
35
- const raw = await getHMSFire();
39
+ if (signal?.aborted) throw signal.reason;
40
+ const raw = await getHMSFire({ signal });
36
41
  const filtered = raw.filter((d): d is HMSFireGeoJSON & { frp: number } => d.frp !== null);
37
42
  const deduped = deduplicateByCoordinates(filtered);
38
43
  this.fire = deduped;
39
44
  this.fireGroups = computeFireGroups(deduped);
40
45
  } catch (err) {
46
+ if (signal?.aborted) throw err;
41
47
  console.error("HMS fire load failed:", err);
42
48
  }
43
49
  }
@@ -1,7 +1,8 @@
1
1
  <script lang="ts">
2
2
  import { BellCheckIcon, BellOffIcon, ChevronDownIcon } from "@lucide/svelte";
3
- import { getSubscriptions, subscribe, unsubscribe } from "@sjvair/sdk/account";
3
+ import { subscribe, unsubscribe } from "@sjvair/sdk/account";
4
4
  import { monitorsManager } from "$lib";
5
+ import { getApiToken, loadSubscriptionLevel } from "$lib/monitors/subscriptions";
5
6
 
6
7
  interface MonitorSubscriptionManagerProps {
7
8
  monitorId: string;
@@ -35,24 +36,24 @@
35
36
  });
36
37
 
37
38
  $effect(() => {
38
- void monitorId;
39
+ const id = monitorId;
39
40
  open = false;
40
41
  subscribedLevel = null;
41
- loadSubscription();
42
- });
43
-
44
- async function loadSubscription() {
45
- const id = monitorId;
42
+ const controller = new AbortController();
43
+ const { signal } = controller;
46
44
  loading = true;
47
- try {
48
- const subscriptions = await getSubscriptions(getApiToken());
49
- if (id !== monitorId) return;
50
- const match = subscriptions.find((s) => s.monitor === id);
51
- subscribedLevel = match?.level ?? null;
52
- } finally {
53
- if (id === monitorId) loading = false;
54
- }
55
- }
45
+ loadSubscriptionLevel(id, { signal })
46
+ .then((level) => {
47
+ if (!signal.aborted) subscribedLevel = level;
48
+ })
49
+ .catch((error) => {
50
+ if (!signal.aborted) throw error;
51
+ })
52
+ .finally(() => {
53
+ if (!signal.aborted) loading = false;
54
+ });
55
+ return () => controller.abort();
56
+ });
56
57
 
57
58
  async function handleLevelClick(level: string | null) {
58
59
  if (saving || !level) return;
@@ -75,12 +76,6 @@
75
76
  }
76
77
  open = false;
77
78
  }
78
-
79
- function getApiToken(): string {
80
- return (
81
- (globalThis as unknown as Record<string, Record<string, string>>)["USER"]?.api_token ?? ""
82
- );
83
- }
84
79
  </script>
85
80
 
86
81
  <div class="relative select-none">
@@ -13,15 +13,17 @@ export class MonitorDetailsState {
13
13
  #id: string | null = null;
14
14
  // Bumped per request; a response for anything but the latest request is dropped.
15
15
  #request = 0;
16
+ #controller: AbortController | null = null;
16
17
 
17
18
  async load(id: string): Promise<void> {
18
19
  this.#id = id;
19
20
  const request = ++this.#request;
21
+ const { signal } = this.#restart();
20
22
  this.monitor = null;
21
23
  this.status = "loading";
22
24
 
23
25
  try {
24
- const monitor = await getMonitorDetails(id);
26
+ const monitor = await getMonitorDetails(id, { signal });
25
27
  if (request !== this.#request) return;
26
28
  this.monitor = monitor;
27
29
  this.status = "ready";
@@ -40,16 +42,23 @@ export class MonitorDetailsState {
40
42
  async refresh(): Promise<void> {
41
43
  if (this.status !== "ready" || this.#id === null) return;
42
44
  const request = ++this.#request;
45
+ const { signal } = this.#restart();
43
46
 
44
47
  try {
45
- const monitor = await getMonitorDetails(this.#id);
48
+ const monitor = await getMonitorDetails(this.#id, { signal });
46
49
  if (request !== this.#request) return;
47
50
  this.monitor = monitor;
48
51
  } catch (error) {
49
- if (request === this.#request) console.error(error);
52
+ if (!signal.aborted && request === this.#request) console.error(error);
50
53
  }
51
54
  }
52
55
 
56
+ /** Aborts whatever request is in flight and starts a new controller for the next one. */
57
+ #restart(): AbortController {
58
+ this.#controller?.abort();
59
+ return (this.#controller = new AbortController());
60
+ }
61
+
53
62
  retry(): Promise<void> {
54
63
  return this.#id === null ? Promise.resolve() : this.load(this.#id);
55
64
  }
@@ -10,6 +10,7 @@ import {
10
10
  import { XMap } from "@tstk/builtin-extensions";
11
11
  import { Interval } from "@tstk/utils";
12
12
  import { untrack } from "svelte";
13
+ import { anySignal, untilAborted, type SignalOptions } from "$lib/abort";
13
14
  import type { MonitorsDataSource, MonitorsPollutant } from "./types";
14
15
 
15
16
  export interface MonitorsInitOptions {
@@ -18,6 +19,24 @@ export interface MonitorsInitOptions {
18
19
  * load the data without showing a map (`resumeAutoUpdate()` starts polling later).
19
20
  */
20
21
  autoUpdate?: boolean;
22
+ /**
23
+ * Stops waiting: the returned promise rejects with the signal's reason. The shared run keeps
24
+ * going for the other calls waiting on it, and is cancelled only when every call waiting on it
25
+ * passed a signal and all of those aborted. An aborted call's pollutant and `autoUpdate` stay
26
+ * recorded. Ignored once initialized.
27
+ */
28
+ signal?: AbortSignal;
29
+ }
30
+
31
+ /** One shared init run and the calls waiting on it. */
32
+ interface InitRun {
33
+ promise: Promise<void>;
34
+ controller: AbortController;
35
+ /** Calls waiting with a signal, and how many of those aborted. */
36
+ signalled: number;
37
+ aborted: number;
38
+ /** Whether any call waits without a signal, which keeps the run going. */
39
+ unsignalled: boolean;
21
40
  }
22
41
 
23
42
  class MonitorsManager implements MonitorsDataSource {
@@ -35,9 +54,10 @@ class MonitorsManager implements MonitorsDataSource {
35
54
  this.meta?.entryType(this.pollutant ?? this.meta.default_pollutant).asIter.levels || null
36
55
  );
37
56
 
38
- #initPromise: Promise<void> | null = null;
57
+ #initRun: InitRun | null = null;
39
58
  #requestedPollutant: MonitorsPollutant | null = null;
40
59
  #requestedAutoUpdate = false;
60
+ #updateController: AbortController | null = null;
41
61
 
42
62
  constructor() {
43
63
  // Refetch monitors when the pollutant changes after the initial load, wherever the change
@@ -70,39 +90,86 @@ class MonitorsManager implements MonitorsDataSource {
70
90
  init(urlPollutant?: string | number | boolean, options: MonitorsInitOptions = {}): Promise<void> {
71
91
  if (this.initialized) return Promise.resolve();
72
92
 
93
+ const { signal } = options;
94
+ // Nothing is recorded or started for a call that is already cancelled.
95
+ if (signal?.aborted) return Promise.reject(signal.reason);
96
+
73
97
  if (options.autoUpdate !== false) this.#requestedAutoUpdate = true;
74
98
 
75
99
  if (urlPollutant === "pm25" || urlPollutant === "o3") {
76
100
  this.#requestedPollutant = urlPollutant;
77
101
  }
78
102
 
79
- this.#initPromise ??= this.#runInit().finally(() => {
80
- this.#initPromise = null;
81
- this.#requestedPollutant = null;
82
- this.#requestedAutoUpdate = false;
83
- });
103
+ const run = (this.#initRun ??= this.#startInitRun());
104
+
105
+ if (!signal) {
106
+ run.unsignalled = true;
107
+ return run.promise;
108
+ }
109
+
110
+ run.signalled++;
111
+ const onAbort = () => {
112
+ run.aborted++;
113
+ if (!run.unsignalled && run.aborted === run.signalled) this.#abortInitRun(run);
114
+ };
115
+ signal.addEventListener("abort", onAbort, { once: true });
116
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
117
+ run.promise.then(cleanup, cleanup);
118
+
119
+ return untilAborted(run.promise, signal);
120
+ }
121
+
122
+ #startInitRun(): InitRun {
123
+ const controller = new AbortController();
124
+ const run: InitRun = {
125
+ promise: this.#runInit(controller.signal).finally(() => {
126
+ if (this.#initRun === run) this.#clearInitRun();
127
+ }),
128
+ controller,
129
+ signalled: 0,
130
+ aborted: 0,
131
+ unsignalled: false
132
+ };
133
+ return run;
134
+ }
135
+
136
+ /**
137
+ * Cancels a run nobody is waiting on. It is detached at once, so a call arriving before its
138
+ * rejection settles starts a fresh run instead of joining the cancelled one.
139
+ */
140
+ #abortInitRun(run: InitRun): void {
141
+ if (this.#initRun !== run) return;
142
+ this.#clearInitRun();
143
+ run.controller.abort();
144
+ }
84
145
 
85
- return this.#initPromise;
146
+ #clearInitRun(): void {
147
+ this.#initRun = null;
148
+ this.#requestedPollutant = null;
149
+ this.#requestedAutoUpdate = false;
86
150
  }
87
151
 
88
- async #runInit(): Promise<void> {
152
+ async #runInit(signal: AbortSignal): Promise<void> {
89
153
  // The pollutant the latest readings were requested for.
90
154
  let fetchedPollutant = this.#requestedPollutant;
91
155
 
92
- const metaRequest = getMonitorsMeta();
93
- const listAndMeta = Promise.all([getMonitorsList(), metaRequest]).then(([list, meta]) => {
94
- this.list = list;
95
- this.meta = meta;
96
- return meta;
97
- });
156
+ const metaRequest = getMonitorsMeta({ signal });
157
+ const listAndMeta = Promise.all([getMonitorsList({ signal }), metaRequest]).then(
158
+ ([list, meta]) => {
159
+ if (signal.aborted) throw signal.reason;
160
+ this.list = list;
161
+ this.meta = meta;
162
+ return meta;
163
+ }
164
+ );
98
165
  // The latest readings only need the pollutant, so they never wait for the list. An explicit
99
166
  // pollutant may still arrive before meta does, in which case the default is never fetched.
100
167
  const latestRequest = fetchedPollutant
101
- ? getMonitorsLatestMap(fetchedPollutant)
168
+ ? getMonitorsLatestMap(fetchedPollutant, signal)
102
169
  : metaRequest.then((meta) => {
103
170
  const pollutant = this.#requestedPollutant ?? meta.default_pollutant;
104
171
  fetchedPollutant = pollutant;
105
- return getMonitorsLatestMap(pollutant);
172
+ return getMonitorsLatestMap(pollutant, signal);
106
173
  });
107
174
 
108
175
  // One Promise.all so a failure in either never leaves the other unhandled.
@@ -112,11 +179,14 @@ class MonitorsManager implements MonitorsDataSource {
112
179
  // An explicit pollutant may have arrived while the requests above were in flight.
113
180
  let wanted = this.#requestedPollutant ?? meta.default_pollutant;
114
181
  while (wanted !== fetchedPollutant) {
115
- latest = await getMonitorsLatestMap(wanted);
182
+ latest = await getMonitorsLatestMap(wanted, signal);
116
183
  fetchedPollutant = wanted;
117
184
  wanted = this.#requestedPollutant ?? meta.default_pollutant;
118
185
  }
119
186
 
187
+ // Cancelled while a request that ignores the signal was settling: leave state to the next run.
188
+ if (signal.aborted) throw signal.reason;
189
+
120
190
  this.pollutant = fetchedPollutant;
121
191
  this.latest = latest;
122
192
  if (this.#requestedAutoUpdate) this.autoUpdate.start();
@@ -135,28 +205,70 @@ class MonitorsManager implements MonitorsDataSource {
135
205
  this.autoUpdate.start();
136
206
  }
137
207
 
138
- async update(): Promise<void> {
208
+ /**
209
+ * Refreshes the list and the latest readings. Starting an update aborts the previous
210
+ * in-flight one, which then resolves without touching state. Pass `signal` to cancel from the
211
+ * outside: that rejects with the signal's reason.
212
+ */
213
+ async update(options: SignalOptions = {}): Promise<void> {
139
214
  if (!this.initialized) return;
140
215
 
141
- const pollutant = this.pollutant;
142
- const [list, latest] = await Promise.all([
143
- getMonitorsList(),
144
- getMonitorsLatestMap(pollutant || this.meta?.default_pollutant || "pm25")
145
- ]);
216
+ // Nothing has started yet, so an in-flight update is left alone.
217
+ if (options.signal?.aborted) throw options.signal.reason;
146
218
 
147
- // The pollutant changed while this was in flight: these readings belong to the previous
148
- // one, and the change has already started its own update.
149
- if (this.pollutant !== pollutant) return;
219
+ const supersededInFlight = this.#updateController !== null;
220
+ this.#updateController?.abort();
221
+ const controller = new AbortController();
222
+ this.#updateController = controller;
223
+ const combined = anySignal(controller.signal, options.signal);
150
224
 
151
- this.list = list;
152
- this.latest = latest;
225
+ // The caller cancelled this update. If it is still the latest and it cut off an update that
226
+ // was in flight, that update's data is lost too, so refresh again without a signal.
227
+ const callerAborted = (signal: AbortSignal) => {
228
+ if (supersededInFlight && this.#updateController === controller) {
229
+ this.update().catch(console.error);
230
+ }
231
+ return signal.reason;
232
+ };
233
+
234
+ // Set once callerAborted has run, so the catch below does not run it a second time.
235
+ let abortedError: unknown;
236
+
237
+ try {
238
+ const pollutant = this.pollutant;
239
+ const [list, latest] = await Promise.all([
240
+ getMonitorsList({ signal: combined.signal }),
241
+ getMonitorsLatestMap(pollutant || this.meta?.default_pollutant || "pm25", combined.signal)
242
+ ]);
243
+
244
+ // Superseded or cancelled while the requests were settling.
245
+ if (options.signal?.aborted) throw (abortedError = callerAborted(options.signal));
246
+ if (controller.signal.aborted) return;
247
+
248
+ // The pollutant changed while this was in flight: these readings belong to the previous
249
+ // one, and the change has already started its own update.
250
+ if (this.pollutant !== pollutant) return;
251
+
252
+ this.list = list;
253
+ this.latest = latest;
254
+ } catch (error) {
255
+ // The caller's own abort rejects with its reason. A superseded update resolves quietly.
256
+ if (options.signal?.aborted)
257
+ throw error === abortedError ? error : callerAborted(options.signal);
258
+ if (controller.signal.aborted) return;
259
+ throw error;
260
+ } finally {
261
+ combined.dispose();
262
+ if (this.#updateController === controller) this.#updateController = null;
263
+ }
153
264
  }
154
265
  }
155
266
 
156
267
  async function getMonitorsLatestMap(
157
- pollutant: "pm25" | "o3"
268
+ pollutant: "pm25" | "o3",
269
+ signal?: AbortSignal
158
270
  ): Promise<XMap<string, MonitorLatestType<"pm25" | "o3">>> {
159
- const monitors = await getMonitors(pollutant);
271
+ const monitors = await getMonitors(pollutant, undefined, { signal });
160
272
  const latest = new XMap<string, MonitorLatestType<"pm25" | "o3">>();
161
273
 
162
274
  for (const monitor of monitors) {
@@ -0,0 +1,17 @@
1
+ import { getSubscriptions } from "@sjvair/sdk/account";
2
+ import type { SignalOptions } from "$lib/abort";
3
+
4
+ export function getApiToken(): string | undefined {
5
+ const token = (globalThis as unknown as Record<string, Record<string, unknown> | undefined>)[
6
+ "USER"
7
+ ]?.api_token;
8
+ return typeof token === "string" && token !== "" ? token : undefined;
9
+ }
10
+
11
+ export async function loadSubscriptionLevel(
12
+ monitorId: string,
13
+ options?: SignalOptions
14
+ ): Promise<string | null> {
15
+ const subscriptions = await getSubscriptions(getApiToken(), { signal: options?.signal });
16
+ return subscriptions.find((s) => s.monitor === monitorId)?.level ?? null;
17
+ }