signalk-siparu 0.1.46 → 0.2.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.
package/README.md CHANGED
@@ -134,10 +134,15 @@ Exactly one kind of message travels the other way:
134
134
  Anything else the shore sends is ignored, because a boat takes no command and so
135
135
  there is nothing else to hear.
136
136
 
137
- Once the account has named a device to seal to, the report itself is encrypted
138
- to that device and the relay carries a block it cannot read. Three things stay
139
- legible beside it, and they are worth stating plainly because they are what a
140
- carrier can see:
137
+ She reports encrypted or she does not report. Until the account has named a
138
+ device to seal to, nothing goes ashore at all: she keeps recording her own
139
+ history, and her own screen says why nothing is leaving. There is no fallback
140
+ that sends a position in the clear, because a boat whose privacy depends on the
141
+ state of a list held elsewhere does not have any.
142
+
143
+ Once a device is named, the report is encrypted to it and the relay carries a
144
+ block it cannot read. Three things stay legible beside it, and they are worth
145
+ stating plainly because they are what a carrier can see:
141
146
 
142
147
  - Her identifier and the time she sent the frame, so it can be routed at all.
143
148
  - How loud she is: `normal`, `warning` or `alarm`, and nothing about what is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-siparu",
3
- "version": "0.1.46",
3
+ "version": "0.2.0",
4
4
  "description": "Kept aboard, proven ashore. An impartial, timestamped record of every voyage: position, wind, depth and logbook, written on the boat and readable from anywhere. A read-only Signal K plugin with a built-in dashboard.",
5
5
  "keywords": [
6
6
  "signalk-node-server-plugin",
@@ -217,10 +217,10 @@ export interface HealthResult {
217
217
  hours_pending: number;
218
218
  };
219
219
  sealing: {
220
- active: boolean;
221
220
  devices: number;
222
- mode: 'clear' | 'sealed' | 'blocked' | 'none';
221
+ mode: 'sealed' | 'blocked' | 'none';
223
222
  reason: string | null;
223
+ screens: string[];
224
224
  };
225
225
  }
226
226
  export type PhaseKind = 'underway' | 'anchored' | 'moored' | 'stopped';
@@ -0,0 +1,2 @@
1
+ export declare function deviceFingerprint(pub: Uint8Array): string;
2
+ export declare function fingerprintOfEncoded(pub: string): string | null;
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.deviceFingerprint = deviceFingerprint;
4
+ exports.fingerprintOfEncoded = fingerprintOfEncoded;
5
+ const node_crypto_1 = require("node:crypto");
6
+ const LABEL = 'siparu/device-fingerprint/v1';
7
+ const BYTES = 10;
8
+ const GROUP = 4;
9
+ const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
10
+ function deviceFingerprint(pub) {
11
+ if (pub.length !== 32)
12
+ throw new Error(`a device key is 32 bytes, this one is ${pub.length}`);
13
+ const digest = (0, node_crypto_1.createHash)('sha256').update(LABEL).update(pub).digest();
14
+ let out = '';
15
+ let bits = 0;
16
+ let held = 0;
17
+ for (const byte of digest.subarray(0, BYTES)) {
18
+ held = (held << 8) | byte;
19
+ bits += 8;
20
+ while (bits >= 5) {
21
+ bits -= 5;
22
+ out += ALPHABET[(held >> bits) & 31];
23
+ }
24
+ }
25
+ return out.replace(new RegExp(`(.{${GROUP}})(?=.)`, 'g'), '$1-');
26
+ }
27
+ function fingerprintOfEncoded(pub) {
28
+ if (!/^[A-Za-z0-9_-]{43}$/.test(pub))
29
+ return null;
30
+ const raw = Buffer.from(pub, 'base64url');
31
+ return raw.length === 32 ? deviceFingerprint(raw) : null;
32
+ }
@@ -39,6 +39,7 @@ const alarms_1 = require("./alarms");
39
39
  const alertrules_1 = require("./alertrules");
40
40
  const charts_1 = require("./charts");
41
41
  const config_1 = require("./config");
42
+ const fingerprint_1 = require("./fingerprint");
42
43
  const metrics_1 = require("./metrics");
43
44
  const query_1 = require("./query");
44
45
  const pairing_1 = require("./pairing");
@@ -164,8 +165,8 @@ module.exports = (app) => {
164
165
  app.setPluginStatus(`Recording - ${snapshotsToday} rows today${age === null ? ', no data yet' : age > 60 ? `, data age ${age}s` : ''}${silent}`);
165
166
  }
166
167
  function sealingState() {
167
- if (!sealer?.active())
168
- return { mode: 'clear', reason: null };
168
+ if (!sealer || !ensureRemoteLink().getRemote())
169
+ return { mode: 'none', reason: null };
169
170
  return sealer.state();
170
171
  }
171
172
  async function health() {
@@ -193,9 +194,11 @@ module.exports = (app) => {
193
194
  },
194
195
  rollup: await rollups.status(now),
195
196
  sealing: {
196
- active: sealer?.active() ?? false,
197
197
  devices: keySync?.status().devices ?? 0,
198
- ...sealingState()
198
+ ...sealingState(),
199
+ screens: (keySync?.devices() ?? [])
200
+ .map((d) => (0, fingerprint_1.fingerprintOfEncoded)(d.pub))
201
+ .filter((fp) => fp !== null)
199
202
  }
200
203
  };
201
204
  }
@@ -379,7 +382,6 @@ module.exports = (app) => {
379
382
  };
380
383
  },
381
384
  seal: (frame) => seal.seal({ ...frame, alarms: alarms.conditions() }, alarms.level(audible), alarms.note(audible), alarms.risenAt(audible)),
382
- sealed: () => seal.active(),
383
385
  onWatching: () => ks.prompt(),
384
386
  openAsk: (envelope) => seal.open(envelope),
385
387
  sealAnswer: (payload, id) => seal.answer(payload, id),
@@ -390,10 +392,8 @@ module.exports = (app) => {
390
392
  const up = new uplink_1.Uplink({
391
393
  relayUrl: opts.relayUrl,
392
394
  getRemote: () => rl.getRemote(),
393
- frame: () => live(),
394
- sealing: () => seal.active(),
395
395
  debug: (msg) => app.debug(msg),
396
- liveHealthy: () => ws.healthy()
396
+ liveHealthy: () => ws.reporting()
397
397
  });
398
398
  uplink = up;
399
399
  up.start();
@@ -3,6 +3,7 @@ import { type ParsedRuleWrite } from './alertrules';
3
3
  import type { AlertRulesReadResult, SetAlertRulesResponse, PathSeriesResult, SnapshotsQuery, SnapshotsResult, TrackResult, VoyageListResult, PhaseListResult } from './contract';
4
4
  export declare const FRAME_EVERY_MS = 2000;
5
5
  export declare const STILL_FRAME_EVERY_MS = 60000;
6
+ export declare const REPORTING_STALE_MS: number;
6
7
  export declare const PING_EVERY_MS = 25000;
7
8
  export interface LiveSocket {
8
9
  send(data: string): void;
@@ -25,22 +26,17 @@ export interface LiveDeps {
25
26
  relayUrl: string;
26
27
  getRemote: () => RemoteLink | undefined;
27
28
  frame: () => unknown;
28
- seal?: (frame: unknown) => {
29
- mode: 'clear';
30
- } | {
29
+ seal: (frame: unknown) => {
31
30
  mode: 'sealed';
32
31
  frame: unknown;
33
32
  } | {
34
33
  mode: 'blocked';
35
34
  };
36
- sealed?: () => boolean;
37
35
  openAsk?: (envelope: unknown) => {
38
36
  id: string;
39
37
  plaintext: string;
40
38
  } | undefined;
41
- sealAnswer?: (payload: unknown, id: string) => {
42
- mode: 'clear';
43
- } | {
39
+ sealAnswer: (payload: unknown, id: string) => {
44
40
  mode: 'sealed';
45
41
  frame: unknown;
46
42
  } | {
@@ -80,6 +76,7 @@ export declare class LiveUplink {
80
76
  start(): void;
81
77
  stop(): void;
82
78
  healthy(): boolean;
79
+ reporting(): boolean;
83
80
  status(): LiveStatus;
84
81
  reset(): void;
85
82
  private dial;
@@ -1,11 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.LiveUplink = exports.PING_EVERY_MS = exports.STILL_FRAME_EVERY_MS = exports.FRAME_EVERY_MS = void 0;
3
+ exports.LiveUplink = exports.PING_EVERY_MS = exports.REPORTING_STALE_MS = exports.STILL_FRAME_EVERY_MS = exports.FRAME_EVERY_MS = void 0;
4
4
  exports.rpcEnvelope = rpcEnvelope;
5
5
  exports.isWatchingNote = isWatchingNote;
6
6
  const alertrules_1 = require("./alertrules");
7
7
  exports.FRAME_EVERY_MS = 2_000;
8
8
  exports.STILL_FRAME_EVERY_MS = 60_000;
9
+ exports.REPORTING_STALE_MS = exports.STILL_FRAME_EVERY_MS * 3;
9
10
  const UNDERWAY_SOG_MS = 0.3 * 0.514444;
10
11
  exports.PING_EVERY_MS = 25_000;
11
12
  const RECONNECT_BASE_MS = 5_000;
@@ -60,6 +61,11 @@ class LiveUplink {
60
61
  healthy() {
61
62
  return !this.stopped && this.connected;
62
63
  }
64
+ reporting() {
65
+ if (!this.healthy() || this.lastFrameTs === null)
66
+ return false;
67
+ return Date.now() - this.lastFrameTs < exports.REPORTING_STALE_MS;
68
+ }
63
69
  status() {
64
70
  return {
65
71
  connected: this.connected,
@@ -128,33 +134,29 @@ class LiveUplink {
128
134
  this.deps.onWatching?.();
129
135
  return;
130
136
  }
131
- if (this.deps.sealed?.()) {
132
- const opened = this.openSealedAsk(data);
133
- if (opened) {
134
- this.dispatch(gen, opened.plaintext);
135
- return;
136
- }
137
- const asked = rpcEnvelope(data);
138
- if (asked) {
139
- const refusal = {
140
- code: 'SEALED',
141
- message: 'This boat is encrypted. Her records open in the Siparu app.'
142
- };
143
- this.refuse(gen, asked.type === 'history'
144
- ? { type: 'history', id: asked.id, error: refusal }
145
- : asked.type === 'snapshots'
146
- ? { type: 'snapshots', id: asked.id, error: refusal }
147
- : asked.type === 'voyages'
148
- ? { type: 'voyages', id: asked.id, error: refusal }
149
- : asked.type === 'track'
150
- ? { type: 'track', id: asked.id, error: refusal }
151
- : asked.type === 'phases'
152
- ? { type: 'phases', id: asked.id, error: refusal }
153
- : { type: 'alertrules', id: asked.id, error: refusal });
154
- }
137
+ const opened = this.openSealedAsk(data);
138
+ if (opened) {
139
+ this.dispatch(gen, opened.plaintext);
155
140
  return;
156
141
  }
157
- this.dispatch(gen, data);
142
+ const asked = rpcEnvelope(data);
143
+ if (asked) {
144
+ const refusal = {
145
+ code: 'SEALED',
146
+ message: 'This boat is encrypted. Her records open in the Siparu app.'
147
+ };
148
+ this.refuse(gen, asked.type === 'history'
149
+ ? { type: 'history', id: asked.id, error: refusal }
150
+ : asked.type === 'snapshots'
151
+ ? { type: 'snapshots', id: asked.id, error: refusal }
152
+ : asked.type === 'voyages'
153
+ ? { type: 'voyages', id: asked.id, error: refusal }
154
+ : asked.type === 'track'
155
+ ? { type: 'track', id: asked.id, error: refusal }
156
+ : asked.type === 'phases'
157
+ ? { type: 'phases', id: asked.id, error: refusal }
158
+ : { type: 'alertrules', id: asked.id, error: refusal });
159
+ }
158
160
  });
159
161
  sock.onClose((code) => {
160
162
  if (gen !== this.gen)
@@ -211,11 +213,11 @@ class LiveUplink {
211
213
  const frame = this.deps.frame();
212
214
  const sog = frame.sog;
213
215
  this.lastSog = typeof sog === 'number' && Number.isFinite(sog) ? sog : null;
214
- const verdict = this.deps.seal?.(frame) ?? { mode: 'clear' };
216
+ const verdict = this.deps.seal(frame);
215
217
  if (verdict.mode === 'blocked') {
216
218
  return;
217
219
  }
218
- this.sock.send(JSON.stringify(verdict.mode === 'sealed' ? { type: 'sealed', frame: verdict.frame } : frame));
220
+ this.sock.send(JSON.stringify({ type: 'sealed', frame: verdict.frame }));
219
221
  this.lastFrameTs = Date.now();
220
222
  }
221
223
  catch (e) {
@@ -414,16 +416,12 @@ class LiveUplink {
414
416
  if (gen !== this.gen || !this.sock)
415
417
  return;
416
418
  try {
417
- if (this.deps.sealed?.()) {
418
- const verdict = this.deps.sealAnswer?.(msg, msg.id);
419
- if (verdict?.mode !== 'sealed') {
420
- this.deps.debug(`answer to ${msg.id} withheld: it could not be sealed`);
421
- return;
422
- }
423
- this.sock.send(JSON.stringify({ type: 'answer', id: msg.id, frame: verdict.frame }));
419
+ const verdict = this.deps.sealAnswer(msg, msg.id);
420
+ if (verdict.mode !== 'sealed') {
421
+ this.deps.debug(`answer to ${msg.id} withheld: it could not be sealed`);
424
422
  return;
425
423
  }
426
- this.sock.send(JSON.stringify(msg));
424
+ this.sock.send(JSON.stringify({ type: 'answer', id: msg.id, frame: verdict.frame }));
427
425
  }
428
426
  catch (e) {
429
427
  this.deps.debug(`history reply send failed: ${String(e)}`);
@@ -3,8 +3,6 @@ import type { AlertLevel, AlertNote, DevicePublicKey } from './contract';
3
3
  import type { BoatKeyStore } from './keystore';
4
4
  import { type SealedFrame } from './sealing';
5
5
  export type SealVerdict = {
6
- mode: 'clear';
7
- } | {
8
6
  mode: 'sealed';
9
7
  frame: SealedFrame;
10
8
  } | {
@@ -20,7 +18,7 @@ export interface SealerDeps {
20
18
  }
21
19
  export declare const NOTE_BLOCK_BYTES = 256;
22
20
  export interface SealState {
23
- mode: 'clear' | 'sealed' | 'blocked' | 'none';
21
+ mode: 'sealed' | 'blocked' | 'none';
24
22
  reason: string | null;
25
23
  }
26
24
  export declare class Sealer {
@@ -31,7 +29,6 @@ export declare class Sealer {
31
29
  private lastReason;
32
30
  constructor(deps: SealerDeps);
33
31
  state(): SealState;
34
- active(): boolean;
35
32
  seal(frame: unknown, alert?: AlertLevel, note?: AlertNote, risenAt?: number): SealVerdict;
36
33
  private sealNote;
37
34
  answer(payload: unknown, id: string): SealVerdict;
@@ -22,9 +22,6 @@ class Sealer {
22
22
  state() {
23
23
  return { mode: this.lastMode, reason: this.lastReason };
24
24
  }
25
- active() {
26
- return this.deps.devices().length > 0 || this.deps.latched();
27
- }
28
25
  seal(frame, alert, note, risenAt) {
29
26
  const extensions = {};
30
27
  if (alert)
@@ -107,11 +104,9 @@ class Sealer {
107
104
  sealPayload(payload, extensions) {
108
105
  const devices = this.deps.devices();
109
106
  if (devices.length === 0) {
110
- if (!this.deps.latched()) {
111
- this.lastMode = 'clear';
112
- return { mode: 'clear' };
113
- }
114
- return this.blocked('the shore has named no screen this boat can seal to');
107
+ return this.blocked(this.deps.latched()
108
+ ? 'the shore has named no screen this boat can seal to. Authorise one again to bring her back'
109
+ : 'no screen has been authorised to read her yet. Add one in the Siparu app, or on her page ashore');
115
110
  }
116
111
  const boat = this.deps.boatId();
117
112
  const keys = this.deps.keys.get();
@@ -13,8 +13,6 @@ export declare function reportedStatus(socket: {
13
13
  export interface UplinkDeps {
14
14
  relayUrl: string;
15
15
  getRemote: () => RemoteLink | undefined;
16
- frame: () => unknown;
17
- sealing?: () => boolean;
18
16
  debug: (msg: string) => void;
19
17
  intervalMs?: number;
20
18
  liveHealthy?: () => boolean;
@@ -102,7 +102,7 @@ class Uplink {
102
102
  'content-type': 'application/json',
103
103
  authorization: `Bearer ${remote.boatToken}`
104
104
  },
105
- body: JSON.stringify(this.deps.sealing?.() ? { ts: Date.now() } : this.deps.frame()),
105
+ body: JSON.stringify({ ts: Date.now() }),
106
106
  signal: controller.signal
107
107
  });
108
108
  if (res.ok) {
@@ -1 +1 @@
1
- import{a as e}from"./rolldown-runtime-CNC7AqOf.js";import{c as t,g as n,h as r,i,l as a,n as o,o as s,p as c,s as l,t as u,u as d,v as f}from"./index-DRLXkDBe.js";import{t as p}from"./visibleInterval-CFxPzgzx.js";var m=e(f(),1),h={"1m":1,"1h":60,"6h":360,"1d":1440},g={"1m":60,"1h":48,"6h":40,"1d":30},_=15e3;function v(){let[e,t]=(0,m.useState)(`1h`),[n,i]=(0,m.useState)(0),[a,o]=(0,m.useState)(!1),[s,c]=(0,m.useState)([]),[l,u]=(0,m.useState)(null),[d,f]=(0,m.useState)(!1),v=h[e],y=g[e]+n*g[e],b=(0,m.useCallback)(e=>{t(e),i(0),c([]),o(!1)},[]),x=(0,m.useCallback)(async()=>{f(!0),u(null);try{let e=y+1,t=await r.logbook.snapshots({bucket:v,limit:e,order:`desc`});o(t.length>y),c(t.slice(0,y))}catch(e){u(e.message)}finally{f(!1)}},[v,y]);return(0,m.useEffect)(()=>{x();let e=p(x,_);return()=>e()},[x]),{granularity:e,changeGran:b,snaps:s,err:l,busy:d,hasMore:a,loadMore:(0,m.useCallback)(()=>i(e=>e+1),[])}}function y(){let[e,t]=(0,m.useState)(o()),[n,i]=(0,m.useState)([]),[a,s]=(0,m.useState)(null),[l,d]=(0,m.useState)(!1),f=(0,m.useRef)(!0),h=c(6e4),g=o(new Date(h)),v=(0,m.useMemo)(()=>u(e),[e]),y=v+24*36e5,b=e===g;(0,m.useEffect)(()=>{f.current&&e!==g&&t(g)},[g,e]);let x=(0,m.useCallback)(e=>{f.current=e===o(),t(e)},[]),S=(0,m.useCallback)(async()=>{d(!0),s(null);try{let e=await r.logbook.snapshots({from:v,to:y,limit:5e3,order:`desc`,bucket:60});i(e)}catch(e){s(e.message)}finally{d(!1)}},[v,y]);return(0,m.useEffect)(()=>{if(S(),!b)return;let e=p(S,_);return()=>e()},[S,b]),{dateStr:e,setDateStr:x,dayStart:v,isToday:b,snaps:n,err:a,busy:l,prevDay:(0,m.useCallback)(()=>x(o(new Date(v-864e5))),[v,x]),nextDay:(0,m.useCallback)(()=>x(o(new Date(v+864e5))),[v,x]),goToday:(0,m.useCallback)(()=>x(o()),[x])}}var b=n(),x=[`1m`,`1h`,`6h`,`1d`],S={"1m":`Last hour`,"1h":`Last 2 days`,"6h":`Last 10 days`,"1d":`Last month`};function C(){let[e,t]=(0,m.useState)(`live`),[n,r]=(0,m.useState)(()=>localStorage.getItem(`lb:windUnit`)||`kn`),i={mode:e,setMode:t,windUnit:n,toggleWind:()=>r(e=>{let t=e===`kn`?`bft`:`kn`;return localStorage.setItem(`lb:windUnit`,t),t})};return(0,b.jsx)(`div`,{className:`lb`,children:e===`live`?(0,b.jsx)(E,{...i}):(0,b.jsx)(D,{...i})})}function w({mode:e,setMode:t}){return(0,b.jsxs)(`div`,{className:`seg`,children:[(0,b.jsx)(`button`,{className:e===`live`?`on`:``,onClick:()=>t(`live`),children:`Live`}),(0,b.jsx)(`button`,{className:e===`day`?`on`:``,onClick:()=>t(`day`),children:`Day`})]})}function T({windUnit:e,toggleWind:t}){return(0,b.jsxs)(`div`,{className:`lb-cols`,children:[(0,b.jsx)(`span`,{children:`UTC`}),(0,b.jsx)(`span`,{children:`SOG`}),(0,b.jsx)(`span`,{children:`HDG`}),(0,b.jsx)(`span`,{className:`tap`,onClick:t,title:`Tap: knots ⇄ Beaufort`,children:e===`kn`?`TWS`:`BFT`}),(0,b.jsx)(`span`,{children:`BARO`}),(0,b.jsx)(`span`,{children:`DEP`})]})}function E({mode:e,setMode:t,windUnit:n,toggleWind:r}){let{granularity:i,changeGran:a,snaps:o,err:s,busy:c,hasMore:l,loadMore:u}=v();return(0,b.jsxs)(b.Fragment,{children:[(0,b.jsxs)(`div`,{className:`lb-ctrl`,children:[(0,b.jsx)(w,{mode:e,setMode:t}),(0,b.jsx)(`div`,{className:`seg`,children:x.map(e=>(0,b.jsx)(`button`,{className:i===e?`on`:``,onClick:()=>a(e),children:e},e))}),(0,b.jsx)(`span`,{className:`lb-count`,children:o.length})]}),(0,b.jsx)(T,{windUnit:n,toggleWind:r}),(0,b.jsxs)(`div`,{className:`lb-day`,children:[(0,b.jsx)(`span`,{children:S[i]}),(0,b.jsx)(`b`,{children:o.length})]}),s&&(0,b.jsx)(`div`,{className:`lb-err`,children:s}),(0,b.jsx)(O,{snaps:o,windUnit:n,footer:l?(0,b.jsx)(`button`,{className:`lb-more`,onClick:u,disabled:c,children:c?`Loading…`:`Load ${g[i]} more`}):null})]})}function D({mode:e,setMode:t,windUnit:n,toggleWind:r}){let{dateStr:i,setDateStr:a,isToday:s,snaps:c,err:l,busy:u,prevDay:d,nextDay:f,goToday:p}=y(),m=s?`Today · ${new Date(i).toLocaleDateString(`en-GB`,{day:`2-digit`,month:`short`,timeZone:`UTC`})}`:new Date(i).toLocaleDateString(`en-GB`,{weekday:`short`,day:`2-digit`,month:`short`,timeZone:`UTC`}).replace(/,/g,``);return(0,b.jsxs)(b.Fragment,{children:[(0,b.jsxs)(`div`,{className:`lb-ctrl`,children:[(0,b.jsx)(w,{mode:e,setMode:t}),(0,b.jsxs)(`div`,{className:`lb-date`,children:[(0,b.jsx)(`button`,{onClick:d,"aria-label":`Previous day`,children:`‹`}),(0,b.jsx)(`input`,{type:`date`,className:`dt`,value:i,max:o(),onChange:e=>a(e.target.value),style:{border:`1.5px solid var(--rule)`,background:`var(--cell)`,color:`var(--text)`,fontFamily:`var(--sp-font)`,fontSize:12,padding:`5px 7px`}}),(0,b.jsx)(`button`,{onClick:f,disabled:s,"aria-label":`Next day`,children:`›`}),(0,b.jsx)(`button`,{onClick:p,disabled:s,children:`Now`})]})]}),(0,b.jsx)(T,{windUnit:n,toggleWind:r}),(0,b.jsxs)(`div`,{className:`lb-day`,children:[(0,b.jsx)(`span`,{children:m}),(0,b.jsx)(`b`,{children:c.length})]}),l&&(0,b.jsx)(`div`,{className:`lb-err`,children:l}),!u&&c.length===0?(0,b.jsxs)(`div`,{className:`sp-empty`,children:[(0,b.jsx)(`div`,{className:`em-t`,children:`No snapshots`}),(0,b.jsx)(`div`,{className:`em-s`,children:`No telemetry was logged for this day.`})]}):(0,b.jsx)(O,{snaps:c,windUnit:n,footer:null})]})}function O({snaps:e,footer:t,windUnit:n}){return(0,b.jsxs)(`div`,{className:`lb-rows`,children:[e.map(e=>(0,b.jsx)(k,{s:e,windUnit:n},e.ts)),t]})}function k({s:e,windUnit:n}){let r=new Date(e.ts),o=e=>String(e).padStart(2,`0`),c=d(e.sog),u=a(e.heading_true??e.heading_mag),f=l(e.wind_speed_true),p=f===null?`·`:String(n===`kn`?Math.round(f):s(f)??`·`),m=t(e.air_pressure_pa);return(0,b.jsxs)(`div`,{className:`lb-row`,children:[(0,b.jsxs)(`span`,{className:`tm`,children:[o(r.getUTCHours()),`:`,o(r.getUTCMinutes())]}),(0,b.jsx)(`span`,{className:`v`,children:i(c,1)}),(0,b.jsx)(`span`,{className:`v`,children:u===null?`·`:Math.round(u)+`°`}),(0,b.jsx)(`span`,{className:`v`,children:p}),(0,b.jsx)(`span`,{className:`v dim`,children:m===null?`·`:Math.round(m)}),(0,b.jsx)(`span`,{className:`v`,children:e.depth===null?`·`:e.depth.toFixed(1)})]})}function A(){return(0,b.jsx)(C,{})}export{A as default};
1
+ import{a as e}from"./rolldown-runtime-CNC7AqOf.js";import{c as t,g as n,h as r,i,l as a,n as o,o as s,p as c,s as l,t as u,u as d,v as f}from"./index-DAYjCs-4.js";import{t as p}from"./visibleInterval-CFxPzgzx.js";var m=e(f(),1),h={"1m":1,"1h":60,"6h":360,"1d":1440},g={"1m":60,"1h":48,"6h":40,"1d":30},_=15e3;function v(){let[e,t]=(0,m.useState)(`1h`),[n,i]=(0,m.useState)(0),[a,o]=(0,m.useState)(!1),[s,c]=(0,m.useState)([]),[l,u]=(0,m.useState)(null),[d,f]=(0,m.useState)(!1),v=h[e],y=g[e]+n*g[e],b=(0,m.useCallback)(e=>{t(e),i(0),c([]),o(!1)},[]),x=(0,m.useCallback)(async()=>{f(!0),u(null);try{let e=y+1,t=await r.logbook.snapshots({bucket:v,limit:e,order:`desc`});o(t.length>y),c(t.slice(0,y))}catch(e){u(e.message)}finally{f(!1)}},[v,y]);return(0,m.useEffect)(()=>{x();let e=p(x,_);return()=>e()},[x]),{granularity:e,changeGran:b,snaps:s,err:l,busy:d,hasMore:a,loadMore:(0,m.useCallback)(()=>i(e=>e+1),[])}}function y(){let[e,t]=(0,m.useState)(o()),[n,i]=(0,m.useState)([]),[a,s]=(0,m.useState)(null),[l,d]=(0,m.useState)(!1),f=(0,m.useRef)(!0),h=c(6e4),g=o(new Date(h)),v=(0,m.useMemo)(()=>u(e),[e]),y=v+24*36e5,b=e===g;(0,m.useEffect)(()=>{f.current&&e!==g&&t(g)},[g,e]);let x=(0,m.useCallback)(e=>{f.current=e===o(),t(e)},[]),S=(0,m.useCallback)(async()=>{d(!0),s(null);try{let e=await r.logbook.snapshots({from:v,to:y,limit:5e3,order:`desc`,bucket:60});i(e)}catch(e){s(e.message)}finally{d(!1)}},[v,y]);return(0,m.useEffect)(()=>{if(S(),!b)return;let e=p(S,_);return()=>e()},[S,b]),{dateStr:e,setDateStr:x,dayStart:v,isToday:b,snaps:n,err:a,busy:l,prevDay:(0,m.useCallback)(()=>x(o(new Date(v-864e5))),[v,x]),nextDay:(0,m.useCallback)(()=>x(o(new Date(v+864e5))),[v,x]),goToday:(0,m.useCallback)(()=>x(o()),[x])}}var b=n(),x=[`1m`,`1h`,`6h`,`1d`],S={"1m":`Last hour`,"1h":`Last 2 days`,"6h":`Last 10 days`,"1d":`Last month`};function C(){let[e,t]=(0,m.useState)(`live`),[n,r]=(0,m.useState)(()=>localStorage.getItem(`lb:windUnit`)||`kn`),i={mode:e,setMode:t,windUnit:n,toggleWind:()=>r(e=>{let t=e===`kn`?`bft`:`kn`;return localStorage.setItem(`lb:windUnit`,t),t})};return(0,b.jsx)(`div`,{className:`lb`,children:e===`live`?(0,b.jsx)(E,{...i}):(0,b.jsx)(D,{...i})})}function w({mode:e,setMode:t}){return(0,b.jsxs)(`div`,{className:`seg`,children:[(0,b.jsx)(`button`,{className:e===`live`?`on`:``,onClick:()=>t(`live`),children:`Live`}),(0,b.jsx)(`button`,{className:e===`day`?`on`:``,onClick:()=>t(`day`),children:`Day`})]})}function T({windUnit:e,toggleWind:t}){return(0,b.jsxs)(`div`,{className:`lb-cols`,children:[(0,b.jsx)(`span`,{children:`UTC`}),(0,b.jsx)(`span`,{children:`SOG`}),(0,b.jsx)(`span`,{children:`HDG`}),(0,b.jsx)(`span`,{className:`tap`,onClick:t,title:`Tap: knots ⇄ Beaufort`,children:e===`kn`?`TWS`:`BFT`}),(0,b.jsx)(`span`,{children:`BARO`}),(0,b.jsx)(`span`,{children:`DEP`})]})}function E({mode:e,setMode:t,windUnit:n,toggleWind:r}){let{granularity:i,changeGran:a,snaps:o,err:s,busy:c,hasMore:l,loadMore:u}=v();return(0,b.jsxs)(b.Fragment,{children:[(0,b.jsxs)(`div`,{className:`lb-ctrl`,children:[(0,b.jsx)(w,{mode:e,setMode:t}),(0,b.jsx)(`div`,{className:`seg`,children:x.map(e=>(0,b.jsx)(`button`,{className:i===e?`on`:``,onClick:()=>a(e),children:e},e))}),(0,b.jsx)(`span`,{className:`lb-count`,children:o.length})]}),(0,b.jsx)(T,{windUnit:n,toggleWind:r}),(0,b.jsxs)(`div`,{className:`lb-day`,children:[(0,b.jsx)(`span`,{children:S[i]}),(0,b.jsx)(`b`,{children:o.length})]}),s&&(0,b.jsx)(`div`,{className:`lb-err`,children:s}),(0,b.jsx)(O,{snaps:o,windUnit:n,footer:l?(0,b.jsx)(`button`,{className:`lb-more`,onClick:u,disabled:c,children:c?`Loading…`:`Load ${g[i]} more`}):null})]})}function D({mode:e,setMode:t,windUnit:n,toggleWind:r}){let{dateStr:i,setDateStr:a,isToday:s,snaps:c,err:l,busy:u,prevDay:d,nextDay:f,goToday:p}=y(),m=s?`Today · ${new Date(i).toLocaleDateString(`en-GB`,{day:`2-digit`,month:`short`,timeZone:`UTC`})}`:new Date(i).toLocaleDateString(`en-GB`,{weekday:`short`,day:`2-digit`,month:`short`,timeZone:`UTC`}).replace(/,/g,``);return(0,b.jsxs)(b.Fragment,{children:[(0,b.jsxs)(`div`,{className:`lb-ctrl`,children:[(0,b.jsx)(w,{mode:e,setMode:t}),(0,b.jsxs)(`div`,{className:`lb-date`,children:[(0,b.jsx)(`button`,{onClick:d,"aria-label":`Previous day`,children:`‹`}),(0,b.jsx)(`input`,{type:`date`,className:`dt`,value:i,max:o(),onChange:e=>a(e.target.value),style:{border:`1.5px solid var(--rule)`,background:`var(--cell)`,color:`var(--text)`,fontFamily:`var(--sp-font)`,fontSize:12,padding:`5px 7px`}}),(0,b.jsx)(`button`,{onClick:f,disabled:s,"aria-label":`Next day`,children:`›`}),(0,b.jsx)(`button`,{onClick:p,disabled:s,children:`Now`})]})]}),(0,b.jsx)(T,{windUnit:n,toggleWind:r}),(0,b.jsxs)(`div`,{className:`lb-day`,children:[(0,b.jsx)(`span`,{children:m}),(0,b.jsx)(`b`,{children:c.length})]}),l&&(0,b.jsx)(`div`,{className:`lb-err`,children:l}),!u&&c.length===0?(0,b.jsxs)(`div`,{className:`sp-empty`,children:[(0,b.jsx)(`div`,{className:`em-t`,children:`No snapshots`}),(0,b.jsx)(`div`,{className:`em-s`,children:`No telemetry was logged for this day.`})]}):(0,b.jsx)(O,{snaps:c,windUnit:n,footer:null})]})}function O({snaps:e,footer:t,windUnit:n}){return(0,b.jsxs)(`div`,{className:`lb-rows`,children:[e.map(e=>(0,b.jsx)(k,{s:e,windUnit:n},e.ts)),t]})}function k({s:e,windUnit:n}){let r=new Date(e.ts),o=e=>String(e).padStart(2,`0`),c=d(e.sog),u=a(e.heading_true??e.heading_mag),f=l(e.wind_speed_true),p=f===null?`·`:String(n===`kn`?Math.round(f):s(f)??`·`),m=t(e.air_pressure_pa);return(0,b.jsxs)(`div`,{className:`lb-row`,children:[(0,b.jsxs)(`span`,{className:`tm`,children:[o(r.getUTCHours()),`:`,o(r.getUTCMinutes())]}),(0,b.jsx)(`span`,{className:`v`,children:i(c,1)}),(0,b.jsx)(`span`,{className:`v`,children:u===null?`·`:Math.round(u)+`°`}),(0,b.jsx)(`span`,{className:`v`,children:p}),(0,b.jsx)(`span`,{className:`v dim`,children:m===null?`·`:Math.round(m)}),(0,b.jsx)(`span`,{className:`v`,children:e.depth===null?`·`:e.depth.toFixed(1)})]})}function A(){return(0,b.jsx)(C,{})}export{A as default};
@@ -1,4 +1,4 @@
1
- import{a as e}from"./rolldown-runtime-CNC7AqOf.js";import{a as t,d as n,g as r,h as i,i as a,l as o,r as s,u as c,v as l}from"./index-DRLXkDBe.js";import{t as u}from"./visibleInterval-CFxPzgzx.js";import{a as d,i as f,n as p,o as m,r as h,s as g,t as _}from"./style-CJPQfDTN.js";var v=e(l(),1),y={fill:`#ffb938`,stroke:`#b38326`,glow:`rgba(255,185,56,0.65)`,dot:`#050706`};function b(e,t=y){let n=t.fill,r=t.stroke;return e===null||Number.isNaN(e)?`
1
+ import{a as e}from"./rolldown-runtime-CNC7AqOf.js";import{a as t,d as n,g as r,h as i,i as a,l as o,r as s,u as c,v as l}from"./index-DAYjCs-4.js";import{t as u}from"./visibleInterval-CFxPzgzx.js";import{a as d,i as f,n as p,o as m,r as h,s as g,t as _}from"./style-BEJHY9Dg.js";var v=e(l(),1),y={fill:`#ffb938`,stroke:`#b38326`,glow:`rgba(255,185,56,0.65)`,dot:`#050706`};function b(e,t=y){let n=t.fill,r=t.stroke;return e===null||Number.isNaN(e)?`
2
2
  <div style="
3
3
  width: 14px; height: 14px;
4
4
  background: ${n};
@@ -1,4 +1,4 @@
1
- import{a as e}from"./rolldown-runtime-CNC7AqOf.js";import{_ as t,f as n,g as r,h as i,i as a,m as o,r as s,v as c}from"./index-DRLXkDBe.js";import{a as l,i as u,n as d,o as f,s as p,t as m}from"./style-CJPQfDTN.js";var h=e(c(),1),g=[{mode:`total_l`,label:`Litres`},{mode:`total_usgal`,label:`US gallons`},{mode:`total_impgal`,label:`Imp gallons`},{mode:`per_nm`,label:`Litres / nm`},{mode:`nm_per_l`,label:`nm / L`},{mode:`per_hour`,label:`Litres / hour`}],_=.26417205,v=.21996923,y=1e-6;function b(e,t,n){return`${e.toFixed(n)} ${t}`}function x(e,t,n,r){if(e===null||!Number.isFinite(e))return null;switch(r){case`total_l`:return b(e,`L`,+(e<100));case`total_usgal`:return b(e*_,`US gal`,1);case`total_impgal`:return b(e*v,`Imp gal`,1);case`per_nm`:return t>y?b(e/t,`L/nm`,2):null;case`nm_per_l`:return e>y?b(t/e,`nm/L`,2):null;case`per_hour`:return n>y?b(e/n,`L/h`,1):null}}function S(e){if(e===null)return``;let t=String(e);return/[",\n\r]/.test(t)?`"${t.replace(/"/g,`""`)}"`:t}function C(e){if(!(e>0))return``;let t=Math.round(e*60),n=Math.floor(t/60),r=t%60;return n>0?`${n}h ${r}m`:`${r}m`}var w=e=>e===null?null:new Date(e).toISOString(),T=[`id`,`start_utc`,`end_utc`,`duration`,`from`,`to`,`distance_nm`,`hours_underway`,`avg_sog_kn`,`max_sog_kn`,`fuel_used_l`,`start_lat`,`start_lon`,`end_lat`,`end_lon`];function E(e){let t=[...e].sort((e,t)=>e.start_ts-t.start_ts),n=[T.join(`,`)];for(let e of t)n.push([e.id,w(e.start_ts),w(e.end_ts),C(e.hours_underway),e.start_port,e.end_port,e.distance_nm,e.hours_underway,e.avg_sog_kn,e.max_sog_kn,e.fuel_used_l,e.start_lat,e.start_lon,e.end_lat,e.end_lon].map(S).join(`,`));return n.join(`\r
1
+ import{a as e}from"./rolldown-runtime-CNC7AqOf.js";import{_ as t,f as n,g as r,h as i,i as a,m as o,r as s,v as c}from"./index-DAYjCs-4.js";import{a as l,i as u,n as d,o as f,s as p,t as m}from"./style-BEJHY9Dg.js";var h=e(c(),1),g=[{mode:`total_l`,label:`Litres`},{mode:`total_usgal`,label:`US gallons`},{mode:`total_impgal`,label:`Imp gallons`},{mode:`per_nm`,label:`Litres / nm`},{mode:`nm_per_l`,label:`nm / L`},{mode:`per_hour`,label:`Litres / hour`}],_=.26417205,v=.21996923,y=1e-6;function b(e,t,n){return`${e.toFixed(n)} ${t}`}function x(e,t,n,r){if(e===null||!Number.isFinite(e))return null;switch(r){case`total_l`:return b(e,`L`,+(e<100));case`total_usgal`:return b(e*_,`US gal`,1);case`total_impgal`:return b(e*v,`Imp gal`,1);case`per_nm`:return t>y?b(e/t,`L/nm`,2):null;case`nm_per_l`:return e>y?b(t/e,`nm/L`,2):null;case`per_hour`:return n>y?b(e/n,`L/h`,1):null}}function S(e){if(e===null)return``;let t=String(e);return/[",\n\r]/.test(t)?`"${t.replace(/"/g,`""`)}"`:t}function C(e){if(!(e>0))return``;let t=Math.round(e*60),n=Math.floor(t/60),r=t%60;return n>0?`${n}h ${r}m`:`${r}m`}var w=e=>e===null?null:new Date(e).toISOString(),T=[`id`,`start_utc`,`end_utc`,`duration`,`from`,`to`,`distance_nm`,`hours_underway`,`avg_sog_kn`,`max_sog_kn`,`fuel_used_l`,`start_lat`,`start_lon`,`end_lat`,`end_lon`];function E(e){let t=[...e].sort((e,t)=>e.start_ts-t.start_ts),n=[T.join(`,`)];for(let e of t)n.push([e.id,w(e.start_ts),w(e.end_ts),C(e.hours_underway),e.start_port,e.end_port,e.distance_nm,e.hours_underway,e.avg_sog_kn,e.max_sog_kn,e.fuel_used_l,e.start_lat,e.start_lon,e.end_lat,e.end_lon].map(S).join(`,`));return n.join(`\r
2
2
  `)+`\r
3
3
  `}function D(e){return e.replace(/&/g,`&amp;`).replace(/</g,`&lt;`).replace(/>/g,`&gt;`).replace(/"/g,`&quot;`)}function O(e){let t=new Date(e.start_ts).toISOString().slice(0,10);return!e.start_port&&!e.end_port?`Voyage ${e.id} ${t}`:`${t} ${e.start_port??`?`} to ${e.end_port??`?`}`}function k(e,t){let n=D(O(e)),r=t.map(e=>` <trkpt lat="${e.lat}" lon="${e.lon}"><time>${new Date(e.ts).toISOString()}</time></trkpt>`).join(`
4
4
  `);return[`<?xml version="1.0" encoding="UTF-8"?>`,`<gpx version="1.1" creator="Siparu" xmlns="http://www.topografix.com/GPX/1/1">`,` <metadata>`,` <name>${n}</name>`,` <time>${new Date(e.start_ts).toISOString()}</time>`,` </metadata>`,` <trk>`,` <name>${n}</name>`,` <trkseg>`,r,` </trkseg>`,` </trk>`,`</gpx>`,``].join(`