signalk-chiplog 1.0.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/CHANGELOG.md +52 -0
- package/LICENSE +21 -0
- package/README.md +290 -0
- package/index.js +327 -0
- package/lib/api.js +427 -0
- package/lib/database.js +264 -0
- package/lib/detection.js +407 -0
- package/lib/entries.js +345 -0
- package/lib/errors.js +22 -0
- package/lib/event-watcher.js +332 -0
- package/lib/events.js +287 -0
- package/lib/export.js +220 -0
- package/lib/formats.js +215 -0
- package/lib/manoeuvre-types.js +84 -0
- package/lib/observation-recorder.js +152 -0
- package/lib/place-names.js +208 -0
- package/lib/places.js +128 -0
- package/lib/propulsion-detector.js +156 -0
- package/lib/propulsion.js +75 -0
- package/lib/rows.js +15 -0
- package/lib/track-recorder.js +149 -0
- package/lib/track.js +58 -0
- package/lib/usb-scheduler.js +131 -0
- package/lib/validation.js +105 -0
- package/package.json +67 -0
- package/public/app.css +603 -0
- package/public/entry/entry.css +528 -0
- package/public/entry/icons/apple-touch-icon.png +0 -0
- package/public/entry/icons/icon-192.png +0 -0
- package/public/entry/icons/icon-512.png +0 -0
- package/public/entry/index.html +22 -0
- package/public/entry/js/access.mjs +116 -0
- package/public/entry/js/clock.mjs +29 -0
- package/public/entry/js/components/AccessGate.mjs +70 -0
- package/public/entry/js/components/Dialogs.mjs +64 -0
- package/public/entry/js/components/ManoeuvrePad.mjs +106 -0
- package/public/entry/js/components/NotePanel.mjs +34 -0
- package/public/entry/js/components/RecentList.mjs +113 -0
- package/public/entry/js/components/SketchPanel.mjs +181 -0
- package/public/entry/js/components/StatusHeader.mjs +69 -0
- package/public/entry/js/journal.mjs +139 -0
- package/public/entry/js/main.mjs +360 -0
- package/public/entry/js/outbox.mjs +124 -0
- package/public/entry/js/strokes.mjs +81 -0
- package/public/entry/manifest.webmanifest +26 -0
- package/public/entry/sw.js +75 -0
- package/public/icon.svg +7 -0
- package/public/index.html +18 -0
- package/public/js/api.mjs +85 -0
- package/public/js/auth.mjs +41 -0
- package/public/js/components/ExportView.mjs +182 -0
- package/public/js/components/LogView.mjs +132 -0
- package/public/js/components/PassageView.mjs +343 -0
- package/public/js/components/PropulsionStrip.mjs +60 -0
- package/public/js/components/StatusBar.mjs +55 -0
- package/public/js/components/Timeline.mjs +226 -0
- package/public/js/components/TrackMap.mjs +85 -0
- package/public/js/components/common.mjs +70 -0
- package/public/js/context.mjs +56 -0
- package/public/js/days.mjs +56 -0
- package/public/js/format.mjs +84 -0
- package/public/js/i18n.mjs +480 -0
- package/public/js/ids.mjs +10 -0
- package/public/js/main.mjs +48 -0
- package/public/js/status.mjs +21 -0
- package/public/vendor/htm-LICENSE +202 -0
- package/public/vendor/leaflet/LICENSE +26 -0
- package/public/vendor/leaflet/images/layers-2x.png +0 -0
- package/public/vendor/leaflet/images/layers.png +0 -0
- package/public/vendor/leaflet/images/marker-icon-2x.png +0 -0
- package/public/vendor/leaflet/images/marker-icon.png +0 -0
- package/public/vendor/leaflet/images/marker-shadow.png +0 -0
- package/public/vendor/leaflet/leaflet.css +661 -0
- package/public/vendor/leaflet/leaflet.js +6 -0
- package/public/vendor/preact-LICENSE +21 -0
- package/public/vendor/preact-htm.mjs +1 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "Chiplog",
|
|
3
|
+
"short_name": "Chiplog",
|
|
4
|
+
"description": "Logbook entry at the helm: manoeuvres, notes and handwriting",
|
|
5
|
+
"start_url": "./",
|
|
6
|
+
"scope": "./",
|
|
7
|
+
"display": "standalone",
|
|
8
|
+
"orientation": "any",
|
|
9
|
+
"background_color": "#0d151d",
|
|
10
|
+
"theme_color": "#0b4f8a",
|
|
11
|
+
"icons": [
|
|
12
|
+
{ "src": "../icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
|
|
13
|
+
{
|
|
14
|
+
"src": "icons/icon-192.png",
|
|
15
|
+
"sizes": "192x192",
|
|
16
|
+
"type": "image/png",
|
|
17
|
+
"purpose": "any maskable"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"src": "icons/icon-512.png",
|
|
21
|
+
"sizes": "512x512",
|
|
22
|
+
"type": "image/png",
|
|
23
|
+
"purpose": "any maskable"
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Keeps the entry app loadable with no connection to the server. Only the app's
|
|
2
|
+
// own files are handled — network first, so an updated plugin is picked up as
|
|
3
|
+
// soon as the server answers — and never the API: entries made offline wait in
|
|
4
|
+
// the page's outbox instead.
|
|
5
|
+
|
|
6
|
+
const CACHE = 'chiplog-entry';
|
|
7
|
+
|
|
8
|
+
const SHELL = [
|
|
9
|
+
'./',
|
|
10
|
+
'manifest.webmanifest',
|
|
11
|
+
'entry.css',
|
|
12
|
+
'icons/icon-192.png',
|
|
13
|
+
'icons/icon-512.png',
|
|
14
|
+
'icons/apple-touch-icon.png',
|
|
15
|
+
'js/main.mjs',
|
|
16
|
+
'js/access.mjs',
|
|
17
|
+
'js/clock.mjs',
|
|
18
|
+
'js/journal.mjs',
|
|
19
|
+
'js/outbox.mjs',
|
|
20
|
+
'js/strokes.mjs',
|
|
21
|
+
'js/components/AccessGate.mjs',
|
|
22
|
+
'js/components/Dialogs.mjs',
|
|
23
|
+
'js/components/ManoeuvrePad.mjs',
|
|
24
|
+
'js/components/NotePanel.mjs',
|
|
25
|
+
'js/components/RecentList.mjs',
|
|
26
|
+
'js/components/SketchPanel.mjs',
|
|
27
|
+
'js/components/StatusHeader.mjs',
|
|
28
|
+
'../icon.svg',
|
|
29
|
+
'../js/api.mjs',
|
|
30
|
+
'../js/auth.mjs',
|
|
31
|
+
'../js/context.mjs',
|
|
32
|
+
'../js/days.mjs',
|
|
33
|
+
'../js/format.mjs',
|
|
34
|
+
'../js/i18n.mjs',
|
|
35
|
+
'../js/ids.mjs',
|
|
36
|
+
'../js/status.mjs',
|
|
37
|
+
'../js/components/common.mjs',
|
|
38
|
+
'../js/components/Timeline.mjs',
|
|
39
|
+
'../vendor/preact-htm.mjs'
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const shellUrls = () => new Set(SHELL.map((file) => new URL(file, self.registration.scope).href));
|
|
43
|
+
|
|
44
|
+
self.addEventListener('install', (event) => {
|
|
45
|
+
event.waitUntil(
|
|
46
|
+
caches
|
|
47
|
+
.open(CACHE)
|
|
48
|
+
.then((cache) => cache.addAll(SHELL))
|
|
49
|
+
.then(() => self.skipWaiting())
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
self.addEventListener('activate', (event) => {
|
|
54
|
+
event.waitUntil(self.clients.claim());
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
self.addEventListener('fetch', (event) => {
|
|
58
|
+
const { request } = event;
|
|
59
|
+
const url = new URL(request.url);
|
|
60
|
+
url.search = '';
|
|
61
|
+
if (request.method !== 'GET' || !shellUrls().has(url.href)) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
event.respondWith(
|
|
65
|
+
fetch(request)
|
|
66
|
+
.then((response) => {
|
|
67
|
+
if (response.ok) {
|
|
68
|
+
const copy = response.clone();
|
|
69
|
+
caches.open(CACHE).then((cache) => cache.put(url.href, copy));
|
|
70
|
+
}
|
|
71
|
+
return response;
|
|
72
|
+
})
|
|
73
|
+
.catch(() => caches.match(url.href).then((cached) => cached ?? Response.error()))
|
|
74
|
+
);
|
|
75
|
+
});
|
package/public/icon.svg
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|
2
|
+
<rect width="64" height="64" rx="14" fill="#0b4f8a"/>
|
|
3
|
+
<path d="M18 14h24a4 4 0 0 1 4 4v30a4 4 0 0 1-4 4H18z" fill="#f8f4e8"/>
|
|
4
|
+
<path d="M18 14v38" stroke="#c9a227" stroke-width="4"/>
|
|
5
|
+
<path d="M26 24h14M26 31h14M26 38h9" stroke="#0b4f8a" stroke-width="2.5" stroke-linecap="round"/>
|
|
6
|
+
<path d="M40 46c2-5 6-8 10-8-1 4-4 8-10 8z" fill="#e8590c"/>
|
|
7
|
+
</svg>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<meta name="color-scheme" content="light dark" />
|
|
7
|
+
<title>Chiplog</title>
|
|
8
|
+
<link rel="icon" href="icon.svg" type="image/svg+xml" />
|
|
9
|
+
<link rel="stylesheet" href="vendor/leaflet/leaflet.css" />
|
|
10
|
+
<link rel="stylesheet" href="app.css" />
|
|
11
|
+
<script src="vendor/leaflet/leaflet.js"></script>
|
|
12
|
+
<script type="module" src="js/main.mjs"></script>
|
|
13
|
+
</head>
|
|
14
|
+
<body>
|
|
15
|
+
<div id="app"></div>
|
|
16
|
+
<noscript>Chiplog needs JavaScript. / Chiplog nécessite JavaScript.</noscript>
|
|
17
|
+
</body>
|
|
18
|
+
</html>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { deviceToken } from './auth.mjs';
|
|
2
|
+
|
|
3
|
+
const BASE = '/plugins/signalk-chiplog/api';
|
|
4
|
+
|
|
5
|
+
export class RequestError extends Error {
|
|
6
|
+
constructor(status, code, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.code = code;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function apiUrl(path) {
|
|
14
|
+
return `${BASE}${path}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const responseListeners = new Set();
|
|
18
|
+
|
|
19
|
+
// Called with every response from the server and when it was sent and received,
|
|
20
|
+
// which the tablet uses to learn the server's clock.
|
|
21
|
+
export function onResponse(listener) {
|
|
22
|
+
responseListeners.add(listener);
|
|
23
|
+
return () => responseListeners.delete(listener);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function request(method, path, body, { timeoutMs } = {}) {
|
|
27
|
+
const headers = body === undefined ? {} : { 'content-type': 'application/json' };
|
|
28
|
+
const token = deviceToken.get();
|
|
29
|
+
if (token) {
|
|
30
|
+
headers.authorization = `Bearer ${token}`;
|
|
31
|
+
}
|
|
32
|
+
// A flaky boat Wi-Fi can leave a request hanging far longer than the crew
|
|
33
|
+
// should wait; AbortController rather than AbortSignal.timeout, for older tablets.
|
|
34
|
+
const controller = timeoutMs ? new AbortController() : null;
|
|
35
|
+
const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
|
36
|
+
const sentAt = Date.now();
|
|
37
|
+
let response;
|
|
38
|
+
try {
|
|
39
|
+
response = await fetch(apiUrl(path), {
|
|
40
|
+
method,
|
|
41
|
+
credentials: 'same-origin',
|
|
42
|
+
headers,
|
|
43
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
44
|
+
signal: controller?.signal
|
|
45
|
+
});
|
|
46
|
+
} catch (err) {
|
|
47
|
+
throw new RequestError(0, 'network', err.message);
|
|
48
|
+
} finally {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
}
|
|
51
|
+
const receivedAt = Date.now();
|
|
52
|
+
responseListeners.forEach((listener) => listener(response, sentAt, receivedAt));
|
|
53
|
+
|
|
54
|
+
if (response.status === 204) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const data = await response.json().catch(() => null);
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
// A device token the server no longer accepts has been revoked or expired.
|
|
60
|
+
if (response.status === 401 && token) {
|
|
61
|
+
deviceToken.clear();
|
|
62
|
+
}
|
|
63
|
+
// Signal K answers access refusals itself, without the plugin's envelope.
|
|
64
|
+
const code =
|
|
65
|
+
response.status === 401 || response.status === 403
|
|
66
|
+
? 'forbidden'
|
|
67
|
+
: (data?.error?.code ?? 'http_error');
|
|
68
|
+
throw new RequestError(response.status, code, data?.error?.message ?? response.statusText);
|
|
69
|
+
}
|
|
70
|
+
return data;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const get = (path) => request('GET', path);
|
|
74
|
+
|
|
75
|
+
export async function fetchAll(path) {
|
|
76
|
+
const separator = path.includes('?') ? '&' : '?';
|
|
77
|
+
const items = [];
|
|
78
|
+
for (;;) {
|
|
79
|
+
const page = await get(`${path}${separator}limit=500&offset=${items.length}`);
|
|
80
|
+
items.push(...page.items);
|
|
81
|
+
if (page.items.length === 0 || items.length >= page.total) {
|
|
82
|
+
return items;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// The token Signal K issues to this device once an administrator approves its
|
|
2
|
+
// access request. Kept in localStorage, which may be missing or refuse writes
|
|
3
|
+
// (private browsing): the app then simply asks again.
|
|
4
|
+
|
|
5
|
+
const TOKEN_KEY = 'chiplog.deviceToken';
|
|
6
|
+
|
|
7
|
+
function browserStorage() {
|
|
8
|
+
try {
|
|
9
|
+
return globalThis.localStorage ?? null;
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createTokenStore(storage = browserStorage()) {
|
|
16
|
+
return {
|
|
17
|
+
get() {
|
|
18
|
+
try {
|
|
19
|
+
return storage?.getItem(TOKEN_KEY) ?? null;
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
set(token) {
|
|
25
|
+
try {
|
|
26
|
+
storage?.setItem(TOKEN_KEY, token);
|
|
27
|
+
} catch {
|
|
28
|
+
// Not persisted: the token lasts as long as this page.
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
clear() {
|
|
32
|
+
try {
|
|
33
|
+
storage?.removeItem(TOKEN_KEY);
|
|
34
|
+
} catch {
|
|
35
|
+
// Nothing stored to clear.
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const deviceToken = createTokenStore();
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { html, useState } from '../../vendor/preact-htm.mjs';
|
|
2
|
+
import { apiUrl, get, request } from '../api.mjs';
|
|
3
|
+
import { useLocale, usePolling } from '../context.mjs';
|
|
4
|
+
import { ErrorNotice } from './common.mjs';
|
|
5
|
+
|
|
6
|
+
const PLUGIN_CONFIGURATION = '/admin/#/serverConfiguration/plugins/signalk-chiplog';
|
|
7
|
+
|
|
8
|
+
// Date inputs give local calendar dates; the API wants instants, `to` exclusive.
|
|
9
|
+
function localMidnight(value, dayOffset = 0) {
|
|
10
|
+
const [year, month, day] = value.split('-').map(Number);
|
|
11
|
+
return new Date(year, month - 1, day + dayOffset);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function exportUrl(format, from, to) {
|
|
15
|
+
const params = new URLSearchParams({ format });
|
|
16
|
+
if (from) {
|
|
17
|
+
params.set('from', localMidnight(from).toISOString());
|
|
18
|
+
}
|
|
19
|
+
if (to) {
|
|
20
|
+
params.set('to', localMidnight(to, 1).toISOString());
|
|
21
|
+
}
|
|
22
|
+
return apiUrl(`/export?${params}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const STATUS_REFRESH_MS = 30 * 1000;
|
|
26
|
+
|
|
27
|
+
function scheduleText({ intervalMinutes, onArrival }, t) {
|
|
28
|
+
if (intervalMinutes > 0) {
|
|
29
|
+
return onArrival
|
|
30
|
+
? t('export.usbScheduleBoth', { minutes: intervalMinutes })
|
|
31
|
+
: t('export.usbScheduleInterval', { minutes: intervalMinutes });
|
|
32
|
+
}
|
|
33
|
+
return onArrival ? t('export.usbScheduleArrival') : t('export.usbScheduleNone');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// What the automatic copy has been doing, so a drive that fell out is noticed
|
|
37
|
+
// before it is needed.
|
|
38
|
+
function UsbStatus({ status }) {
|
|
39
|
+
const { t, format } = useLocale();
|
|
40
|
+
if (!status?.configured) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
const when = (value) => `${format.shortDate(value)} ${format.time(value)}`;
|
|
44
|
+
const { lastSuccess, lastError, nextAt } = status;
|
|
45
|
+
return html`
|
|
46
|
+
<ul class="usb-status">
|
|
47
|
+
<li>${scheduleText(status, t)}</li>
|
|
48
|
+
${
|
|
49
|
+
lastError &&
|
|
50
|
+
html`<li class="notice notice-error">
|
|
51
|
+
${t('export.usbLastError', {
|
|
52
|
+
time: when(lastError.at),
|
|
53
|
+
message:
|
|
54
|
+
lastError.code === 'usb_export_unavailable'
|
|
55
|
+
? t('export.usbUnavailable')
|
|
56
|
+
: lastError.message
|
|
57
|
+
})}
|
|
58
|
+
</li>`
|
|
59
|
+
}
|
|
60
|
+
<li>
|
|
61
|
+
${
|
|
62
|
+
lastSuccess
|
|
63
|
+
? t('export.usbLastCopy', {
|
|
64
|
+
time: when(lastSuccess.at),
|
|
65
|
+
written: lastSuccess.written,
|
|
66
|
+
unchanged: lastSuccess.unchanged
|
|
67
|
+
})
|
|
68
|
+
: t('export.usbNeverCopied')
|
|
69
|
+
}
|
|
70
|
+
</li>
|
|
71
|
+
${nextAt && html`<li class="muted">${t('export.usbNextCopy', { time: format.time(nextAt) })}</li>`}
|
|
72
|
+
</ul>
|
|
73
|
+
`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function UsbResult({ outcome }) {
|
|
77
|
+
const { t } = useLocale();
|
|
78
|
+
if (!outcome) {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
if (outcome.result) {
|
|
82
|
+
return html`<p class="notice notice-ok">
|
|
83
|
+
${t('export.usbWritten', {
|
|
84
|
+
written: outcome.result.written,
|
|
85
|
+
unchanged: outcome.result.unchanged,
|
|
86
|
+
directory: outcome.result.directory
|
|
87
|
+
})}
|
|
88
|
+
</p>`;
|
|
89
|
+
}
|
|
90
|
+
const { error } = outcome;
|
|
91
|
+
if (error.code === 'usb_export_not_configured') {
|
|
92
|
+
return html`<p class="notice notice-error">
|
|
93
|
+
${t('export.usbNotConfigured')}
|
|
94
|
+
<a href=${PLUGIN_CONFIGURATION}>${t('export.pluginConfiguration')}</a>
|
|
95
|
+
</p>`;
|
|
96
|
+
}
|
|
97
|
+
if (error.code === 'usb_export_unavailable') {
|
|
98
|
+
return html`<p class="notice notice-error">${t('export.usbUnavailable')}</p>`;
|
|
99
|
+
}
|
|
100
|
+
return html`<${ErrorNotice} error=${error} />`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function ExportView() {
|
|
104
|
+
const { t } = useLocale();
|
|
105
|
+
const [from, setFrom] = useState('');
|
|
106
|
+
const [to, setTo] = useState('');
|
|
107
|
+
const [usb, setUsb] = useState(null);
|
|
108
|
+
const [writing, setWriting] = useState(false);
|
|
109
|
+
const [status, setStatus] = useState(null);
|
|
110
|
+
const [version, setVersion] = useState(0);
|
|
111
|
+
const invalid = Boolean(from && to && to < from);
|
|
112
|
+
|
|
113
|
+
usePolling(
|
|
114
|
+
(isCurrent) => {
|
|
115
|
+
get('/export/usb')
|
|
116
|
+
.then((next) => isCurrent() && setStatus(next))
|
|
117
|
+
.catch(() => {
|
|
118
|
+
// The write button reports access and configuration problems itself.
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
STATUS_REFRESH_MS,
|
|
122
|
+
[version]
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const writeUsb = async () => {
|
|
126
|
+
setWriting(true);
|
|
127
|
+
setUsb(null);
|
|
128
|
+
try {
|
|
129
|
+
setUsb({ result: await request('POST', '/export/usb') });
|
|
130
|
+
} catch (error) {
|
|
131
|
+
setUsb({ error });
|
|
132
|
+
} finally {
|
|
133
|
+
setWriting(false);
|
|
134
|
+
setVersion((v) => v + 1);
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
return html`
|
|
139
|
+
<h1 class="page-title">${t('export.title')}</h1>
|
|
140
|
+
|
|
141
|
+
<section class="card">
|
|
142
|
+
<p>${t('export.intro')}</p>
|
|
143
|
+
<div class="date-range">
|
|
144
|
+
<label>
|
|
145
|
+
${t('export.from')}
|
|
146
|
+
<input
|
|
147
|
+
type="date"
|
|
148
|
+
value=${from}
|
|
149
|
+
onInput=${(event) => setFrom(event.currentTarget.value)}
|
|
150
|
+
/>
|
|
151
|
+
</label>
|
|
152
|
+
<label>
|
|
153
|
+
${t('export.to')}
|
|
154
|
+
<input type="date" value=${to} onInput=${(event) => setTo(event.currentTarget.value)} />
|
|
155
|
+
</label>
|
|
156
|
+
</div>
|
|
157
|
+
<p class="muted">${t('export.allHint')}</p>
|
|
158
|
+
${invalid && html`<p class="notice notice-error">${t('export.invalidRange')}</p>`}
|
|
159
|
+
<ul class="downloads">
|
|
160
|
+
${['json', 'csv', 'gpx'].map(
|
|
161
|
+
(format) =>
|
|
162
|
+
html`<li key=${format}>
|
|
163
|
+
${
|
|
164
|
+
invalid
|
|
165
|
+
? html`<span class="muted">${t(`export.${format}`)}</span>`
|
|
166
|
+
: html`<a href=${exportUrl(format, from, to)} download>${t(`export.${format}`)}</a>`
|
|
167
|
+
}
|
|
168
|
+
</li>`
|
|
169
|
+
)}
|
|
170
|
+
<li><span class="muted">${t('export.pdf')}</span></li>
|
|
171
|
+
</ul>
|
|
172
|
+
</section>
|
|
173
|
+
|
|
174
|
+
<section class="card">
|
|
175
|
+
<h2>${t('export.usbTitle')}</h2>
|
|
176
|
+
<p>${t('export.usbIntro')}</p>
|
|
177
|
+
<${UsbStatus} status=${status} />
|
|
178
|
+
<button type="button" disabled=${writing} onClick=${writeUsb}>${t('export.usbWrite')}</button>
|
|
179
|
+
<${UsbResult} outcome=${usb} />
|
|
180
|
+
</section>
|
|
181
|
+
`;
|
|
182
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { html, useRef, useState } from '../../vendor/preact-htm.mjs';
|
|
2
|
+
import { get } from '../api.mjs';
|
|
3
|
+
import { useLocale, usePolling } from '../context.mjs';
|
|
4
|
+
import { groupByDay } from '../days.mjs';
|
|
5
|
+
import { elapsedSeconds, EngineSailBar, ErrorNotice, Loading, PlaceName } from './common.mjs';
|
|
6
|
+
|
|
7
|
+
const PAGE_SIZE = 50;
|
|
8
|
+
const API_PAGE_LIMIT = 500;
|
|
9
|
+
const REFRESH_MS = 60 * 1000;
|
|
10
|
+
|
|
11
|
+
// Reloads everything loaded so far, so a refresh does not drop older pages.
|
|
12
|
+
async function loadEntries(count) {
|
|
13
|
+
const items = [];
|
|
14
|
+
let total = 0;
|
|
15
|
+
while (items.length < count) {
|
|
16
|
+
const limit = Math.min(API_PAGE_LIMIT, count - items.length);
|
|
17
|
+
const page = await get(`/entries?limit=${limit}&offset=${items.length}`);
|
|
18
|
+
total = page.total;
|
|
19
|
+
items.push(...page.items);
|
|
20
|
+
if (page.items.length === 0 || items.length >= total) {
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return { items, total };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function PassageCard({ entry, continuesFromPreviousDay, continuesNextDay }) {
|
|
28
|
+
const { t, format } = useLocale();
|
|
29
|
+
const at = (value, withDate) =>
|
|
30
|
+
withDate ? `${format.shortDate(value)} ${format.time(value)}` : format.time(value);
|
|
31
|
+
|
|
32
|
+
return html`
|
|
33
|
+
<a
|
|
34
|
+
class=${entry.state === 'active' ? 'passage-card passage-active' : 'passage-card'}
|
|
35
|
+
href=${`#/passages/${entry.id}`}
|
|
36
|
+
>
|
|
37
|
+
<div class="passage-times">
|
|
38
|
+
${`${at(entry.startTime, continuesFromPreviousDay)} – ${
|
|
39
|
+
entry.endTime ? at(entry.endTime, continuesNextDay) : t('log.inProgress')
|
|
40
|
+
}`}
|
|
41
|
+
</div>
|
|
42
|
+
<div class="passage-places">
|
|
43
|
+
<${PlaceName} name=${entry.startPlaceName} pending=${entry.startPlacePending} />
|
|
44
|
+
<span aria-hidden="true"> → </span>
|
|
45
|
+
${
|
|
46
|
+
entry.endTime
|
|
47
|
+
? html`<${PlaceName} name=${entry.endPlaceName} pending=${entry.endPlacePending} />`
|
|
48
|
+
: html`<span class="place">…</span>`
|
|
49
|
+
}
|
|
50
|
+
</div>
|
|
51
|
+
<div class="passage-facts">
|
|
52
|
+
<span>${format.distance(entry.distance)}</span>
|
|
53
|
+
<span>${format.duration(elapsedSeconds(entry))}</span>
|
|
54
|
+
</div>
|
|
55
|
+
<${EngineSailBar} engine=${entry.engineDuration} sail=${entry.sailDuration} />
|
|
56
|
+
${
|
|
57
|
+
continuesFromPreviousDay &&
|
|
58
|
+
html`<div class="passage-continues">${t('log.fromPreviousDay')}</div>`
|
|
59
|
+
}
|
|
60
|
+
${continuesNextDay && html`<div class="passage-continues">${t('log.toNextDay')}</div>`}
|
|
61
|
+
</a>
|
|
62
|
+
`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function LogView() {
|
|
66
|
+
const { t, format } = useLocale();
|
|
67
|
+
const [log, setLog] = useState(null);
|
|
68
|
+
const [error, setError] = useState(null);
|
|
69
|
+
const [loadingOlder, setLoadingOlder] = useState(false);
|
|
70
|
+
const wanted = useRef(PAGE_SIZE);
|
|
71
|
+
|
|
72
|
+
const reload = (isCurrent = () => true) =>
|
|
73
|
+
loadEntries(wanted.current)
|
|
74
|
+
.then((next) => {
|
|
75
|
+
if (isCurrent()) {
|
|
76
|
+
setLog(next);
|
|
77
|
+
setError(null);
|
|
78
|
+
}
|
|
79
|
+
})
|
|
80
|
+
.catch((err) => isCurrent() && setError(err));
|
|
81
|
+
|
|
82
|
+
usePolling(reload, REFRESH_MS);
|
|
83
|
+
|
|
84
|
+
const loadOlder = () => {
|
|
85
|
+
wanted.current += PAGE_SIZE;
|
|
86
|
+
setLoadingOlder(true);
|
|
87
|
+
reload().finally(() => setLoadingOlder(false));
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
if (!log) {
|
|
91
|
+
return error
|
|
92
|
+
? html`<${ErrorNotice} error=${error} onRetry=${() => reload()} />`
|
|
93
|
+
: html`<${Loading} />`;
|
|
94
|
+
}
|
|
95
|
+
if (log.items.length === 0) {
|
|
96
|
+
return html`
|
|
97
|
+
<h1 class="page-title">${t('log.title')}</h1>
|
|
98
|
+
<p class="empty">${t('log.empty')}</p>
|
|
99
|
+
`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return html`
|
|
103
|
+
<h1 class="page-title">${t('log.title')}</h1>
|
|
104
|
+
<${ErrorNotice} error=${error} />
|
|
105
|
+
${groupByDay(log.items).map(
|
|
106
|
+
(day) => html`
|
|
107
|
+
<section class="day" key=${day.key}>
|
|
108
|
+
<header class="day-header">
|
|
109
|
+
<h2>${format.day(day.date)}</h2>
|
|
110
|
+
${
|
|
111
|
+
day.distance > 0 &&
|
|
112
|
+
html`<span class="day-distance">
|
|
113
|
+
${t('log.dayDistance', { distance: format.distance(day.distance) })}
|
|
114
|
+
</span>`
|
|
115
|
+
}
|
|
116
|
+
</header>
|
|
117
|
+
<ul class="passage-list">
|
|
118
|
+
${day.items.map(
|
|
119
|
+
(item) => html`<li key=${item.entry.id}><${PassageCard} ...${item} /></li>`
|
|
120
|
+
)}
|
|
121
|
+
</ul>
|
|
122
|
+
</section>
|
|
123
|
+
`
|
|
124
|
+
)}
|
|
125
|
+
${
|
|
126
|
+
log.items.length < log.total &&
|
|
127
|
+
html`<button type="button" class="load-older" disabled=${loadingOlder} onClick=${loadOlder}>
|
|
128
|
+
${t('log.loadOlder')}
|
|
129
|
+
</button>`
|
|
130
|
+
}
|
|
131
|
+
`;
|
|
132
|
+
}
|