signalk-chiplog 2.3.0 → 2.4.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 +44 -1
- package/README.md +32 -18
- package/index.js +24 -2
- package/lib/api.js +6 -0
- package/lib/database.js +13 -0
- package/lib/entries.js +23 -2
- package/lib/influx-history.js +59 -28
- package/lib/place-names.js +73 -20
- package/lib/places.js +1 -0
- package/lib/replay-job.js +39 -5
- package/lib/statistics.js +208 -0
- package/package.json +3 -2
- package/public/app.css +207 -4
- package/public/js/calendar.mjs +50 -0
- package/public/js/components/AnimationView.mjs +11 -14
- package/public/js/components/DateRangePicker.mjs +187 -0
- package/public/js/components/ExportView.mjs +11 -21
- package/public/js/components/PassageView.mjs +48 -1
- package/public/js/components/ReplayView.mjs +42 -33
- package/public/js/components/StatisticsView.mjs +249 -0
- package/public/js/context.mjs +3 -0
- package/public/js/format.mjs +8 -0
- package/public/js/i18n.mjs +79 -12
- package/public/js/main.mjs +5 -0
- package/public/js/shortcuts.mjs +31 -0
- package/public/js/statistics.mjs +76 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { html, useEffect, useRef, useState } from '../../vendor/preact-htm.mjs';
|
|
2
|
+
import { useLocale } from '../context.mjs';
|
|
3
|
+
import {
|
|
4
|
+
completeRange,
|
|
5
|
+
monthGrid,
|
|
6
|
+
monthOf,
|
|
7
|
+
monthTitle,
|
|
8
|
+
shiftMonth,
|
|
9
|
+
weekdayLabels
|
|
10
|
+
} from '../calendar.mjs';
|
|
11
|
+
import { dayKey, rangeBoundary } from '../days.mjs';
|
|
12
|
+
|
|
13
|
+
// One control for a period: a button showing it, and a calendar to pick it with
|
|
14
|
+
// two clicks -- the first and the last day. Either end may be left open by the
|
|
15
|
+
// shortcuts around it; a range is only ever changed here by completing one, or
|
|
16
|
+
// by clearing it where any date is allowed (`allowAny`).
|
|
17
|
+
export function DateRangePicker({ from, to, onChange, label, allowAny = true, disabled = false }) {
|
|
18
|
+
const { t, format, language } = useLocale();
|
|
19
|
+
const [open, setOpen] = useState(false);
|
|
20
|
+
const [view, setView] = useState(() => monthOf(from || dayKey(new Date())));
|
|
21
|
+
// The first click of a range being picked, until the second one arrives.
|
|
22
|
+
const [anchor, setAnchor] = useState(null);
|
|
23
|
+
const [hover, setHover] = useState(null);
|
|
24
|
+
const root = useRef(null);
|
|
25
|
+
const trigger = useRef(null);
|
|
26
|
+
|
|
27
|
+
const close = () => {
|
|
28
|
+
setOpen(false);
|
|
29
|
+
setAnchor(null);
|
|
30
|
+
setHover(null);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (!open) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
const outside = (event) => {
|
|
38
|
+
if (!root.current?.contains(event.target)) {
|
|
39
|
+
close();
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const escape = (event) => {
|
|
43
|
+
if (event.key === 'Escape') {
|
|
44
|
+
close();
|
|
45
|
+
trigger.current?.focus();
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
document.addEventListener('pointerdown', outside);
|
|
49
|
+
document.addEventListener('keydown', escape);
|
|
50
|
+
return () => {
|
|
51
|
+
document.removeEventListener('pointerdown', outside);
|
|
52
|
+
document.removeEventListener('keydown', escape);
|
|
53
|
+
};
|
|
54
|
+
}, [open]);
|
|
55
|
+
|
|
56
|
+
const toggle = () => {
|
|
57
|
+
if (open) {
|
|
58
|
+
close();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
setView(monthOf(from || dayKey(new Date())));
|
|
62
|
+
setOpen(true);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const clear = () => {
|
|
66
|
+
onChange({ from: '', to: '' });
|
|
67
|
+
close();
|
|
68
|
+
trigger.current?.focus();
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const pick = (key) => {
|
|
72
|
+
if (anchor === null) {
|
|
73
|
+
setAnchor(key);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
onChange(completeRange(anchor, key));
|
|
77
|
+
close();
|
|
78
|
+
trigger.current?.focus();
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
// What the calendar highlights: the range being picked, with the day under
|
|
82
|
+
// the pointer as its provisional end, or else the one already chosen.
|
|
83
|
+
const shown = anchor === null ? { from, to } : completeRange(anchor, hover ?? anchor);
|
|
84
|
+
const dayText = (key) => format.date(rangeBoundary(key));
|
|
85
|
+
const summary =
|
|
86
|
+
!from && !to
|
|
87
|
+
? allowAny
|
|
88
|
+
? t('range.anyDate')
|
|
89
|
+
: t('range.choose')
|
|
90
|
+
: `${from ? dayText(from) : '…'} – ${to ? dayText(to) : '…'}`;
|
|
91
|
+
const today = dayKey(new Date());
|
|
92
|
+
|
|
93
|
+
const cellClass = (key) => {
|
|
94
|
+
const classes = ['cal-day'];
|
|
95
|
+
if (key === today) {
|
|
96
|
+
classes.push('cal-today');
|
|
97
|
+
}
|
|
98
|
+
if (shown.from && shown.to && key >= shown.from && key <= shown.to) {
|
|
99
|
+
classes.push('cal-in-range');
|
|
100
|
+
}
|
|
101
|
+
if (key === shown.from) {
|
|
102
|
+
classes.push('cal-start');
|
|
103
|
+
}
|
|
104
|
+
if (key === shown.to) {
|
|
105
|
+
classes.push('cal-end');
|
|
106
|
+
}
|
|
107
|
+
return classes.join(' ');
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const nav = (delta, name, text) => html`
|
|
111
|
+
<button
|
|
112
|
+
type="button"
|
|
113
|
+
class="cal-nav"
|
|
114
|
+
aria-label=${name}
|
|
115
|
+
onClick=${() => setView(shiftMonth(view, delta))}
|
|
116
|
+
>
|
|
117
|
+
${text}
|
|
118
|
+
</button>
|
|
119
|
+
`;
|
|
120
|
+
|
|
121
|
+
return html`
|
|
122
|
+
<div class="range-picker" ref=${root}>
|
|
123
|
+
<button
|
|
124
|
+
type="button"
|
|
125
|
+
class="range-trigger"
|
|
126
|
+
ref=${trigger}
|
|
127
|
+
aria-haspopup="dialog"
|
|
128
|
+
aria-expanded=${open}
|
|
129
|
+
disabled=${disabled}
|
|
130
|
+
aria-label=${`${label}: ${summary}`}
|
|
131
|
+
onClick=${toggle}
|
|
132
|
+
>
|
|
133
|
+
<span aria-hidden="true">📅</span>
|
|
134
|
+
<span>${summary}</span>
|
|
135
|
+
</button>
|
|
136
|
+
${
|
|
137
|
+
open &&
|
|
138
|
+
html`
|
|
139
|
+
<div class="range-popover" role="dialog" aria-label=${label}>
|
|
140
|
+
<div class="cal-header">
|
|
141
|
+
${nav(-12, t('range.previousYear'), '«')}
|
|
142
|
+
${nav(-1, t('range.previousMonth'), '‹')}
|
|
143
|
+
<strong class="cal-title" aria-live="polite">${monthTitle(language, view)}</strong>
|
|
144
|
+
${nav(1, t('range.nextMonth'), '›')}
|
|
145
|
+
${nav(12, t('range.nextYear'), '»')}
|
|
146
|
+
</div>
|
|
147
|
+
<div class="cal-grid" onMouseLeave=${() => setHover(null)}>
|
|
148
|
+
${weekdayLabels(language).map(
|
|
149
|
+
(name) => html`<span class="cal-weekday" key=${name}>${name}</span>`
|
|
150
|
+
)}
|
|
151
|
+
${monthGrid(view.year, view.month).map((key, index) =>
|
|
152
|
+
key === null
|
|
153
|
+
? html`<span key=${`blank-${index}`}></span>`
|
|
154
|
+
: html`
|
|
155
|
+
<button
|
|
156
|
+
type="button"
|
|
157
|
+
key=${key}
|
|
158
|
+
class=${cellClass(key)}
|
|
159
|
+
aria-label=${format.day(rangeBoundary(key))}
|
|
160
|
+
aria-pressed=${key === shown.from || key === shown.to}
|
|
161
|
+
onClick=${() => pick(key)}
|
|
162
|
+
onMouseEnter=${() => setHover(key)}
|
|
163
|
+
onFocus=${() => setHover(key)}
|
|
164
|
+
>
|
|
165
|
+
${Number(key.slice(8))}
|
|
166
|
+
</button>
|
|
167
|
+
`
|
|
168
|
+
)}
|
|
169
|
+
</div>
|
|
170
|
+
<div class="cal-footer">
|
|
171
|
+
<span class="muted cal-hint">
|
|
172
|
+
${anchor === null ? t('range.pickStart') : t('range.pickEnd')}
|
|
173
|
+
</span>
|
|
174
|
+
${
|
|
175
|
+
allowAny &&
|
|
176
|
+
(from || to) &&
|
|
177
|
+
html`<button type="button" class="link-button" onClick=${clear}>
|
|
178
|
+
${t('range.anyDate')}
|
|
179
|
+
</button>`
|
|
180
|
+
}
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
`
|
|
184
|
+
}
|
|
185
|
+
</div>
|
|
186
|
+
`;
|
|
187
|
+
}
|
|
@@ -2,6 +2,7 @@ import { html, useState } from '../../vendor/preact-htm.mjs';
|
|
|
2
2
|
import { apiUrl, get, request } from '../api.mjs';
|
|
3
3
|
import { useLocale, usePolling } from '../context.mjs';
|
|
4
4
|
import { rangeBoundary } from '../days.mjs';
|
|
5
|
+
import { DateRangePicker } from './DateRangePicker.mjs';
|
|
5
6
|
import { ErrorNotice } from './common.mjs';
|
|
6
7
|
|
|
7
8
|
const PLUGIN_CONFIGURATION = '/admin/#/serverConfiguration/plugins/signalk-chiplog';
|
|
@@ -108,7 +109,6 @@ export function ExportView() {
|
|
|
108
109
|
const [writing, setWriting] = useState(false);
|
|
109
110
|
const [status, setStatus] = useState(null);
|
|
110
111
|
const [version, setVersion] = useState(0);
|
|
111
|
-
const invalid = Boolean(from && to && to < from);
|
|
112
112
|
|
|
113
113
|
usePolling(
|
|
114
114
|
(isCurrent) => {
|
|
@@ -140,31 +140,21 @@ export function ExportView() {
|
|
|
140
140
|
|
|
141
141
|
<section class="card">
|
|
142
142
|
<p>${t('export.intro')}</p>
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
<label>
|
|
153
|
-
${t('export.to')}
|
|
154
|
-
<input type="date" value=${to} onInput=${(event) => setTo(event.currentTarget.value)} />
|
|
155
|
-
</label>
|
|
156
|
-
</div>
|
|
143
|
+
<${DateRangePicker}
|
|
144
|
+
from=${from}
|
|
145
|
+
to=${to}
|
|
146
|
+
label=${t('range.period')}
|
|
147
|
+
onChange=${(range) => {
|
|
148
|
+
setFrom(range.from);
|
|
149
|
+
setTo(range.to);
|
|
150
|
+
}}
|
|
151
|
+
/>
|
|
157
152
|
<p class="muted">${t('export.allHint')}</p>
|
|
158
|
-
${invalid && html`<p class="notice notice-error">${t('export.invalidRange')}</p>`}
|
|
159
153
|
<ul class="downloads">
|
|
160
154
|
${['pdf', 'json', 'csv', 'gpx'].map(
|
|
161
155
|
(format) =>
|
|
162
156
|
html`<li key=${format}>
|
|
163
|
-
|
|
164
|
-
invalid
|
|
165
|
-
? html`<span class="muted">${t(`export.${format}`)}</span>`
|
|
166
|
-
: html`<a href=${exportUrl(format, from, to, language)} download>${t(`export.${format}`)}</a>`
|
|
167
|
-
}
|
|
157
|
+
<a href=${exportUrl(format, from, to, language)} download>${t(`export.${format}`)}</a>
|
|
168
158
|
</li>`
|
|
169
159
|
)}
|
|
170
160
|
</ul>
|
|
@@ -11,6 +11,7 @@ import { Timeline } from './Timeline.mjs';
|
|
|
11
11
|
import { TrackMap } from './TrackMap.mjs';
|
|
12
12
|
import { TrackScrubber } from './TrackScrubber.mjs';
|
|
13
13
|
import { WeatherCard } from './WeatherCard.mjs';
|
|
14
|
+
import { stepDirection } from '../shortcuts.mjs';
|
|
14
15
|
import { trackPoints } from '../track.mjs';
|
|
15
16
|
|
|
16
17
|
const ACTIVE_REFRESH_MS = 60 * 1000;
|
|
@@ -236,6 +237,14 @@ function NameField({ label, name, pending, busy, onSave }) {
|
|
|
236
237
|
`;
|
|
237
238
|
}
|
|
238
239
|
|
|
240
|
+
// A neighbouring passage, or its place kept as a dimmed label at the log's end.
|
|
241
|
+
// `shortcut` is the key that does the same, told in the tooltip.
|
|
242
|
+
function StepLink({ id, label, shortcut, keyName }) {
|
|
243
|
+
return id === null
|
|
244
|
+
? html`<span class="muted" aria-disabled="true">${label}</span>`
|
|
245
|
+
: html`<a href=${`#/passages/${id}`} title=${shortcut} aria-keyshortcuts=${keyName}>${label}</a>`;
|
|
246
|
+
}
|
|
247
|
+
|
|
239
248
|
export function PassageView({ id }) {
|
|
240
249
|
const { t, format } = useLocale();
|
|
241
250
|
const [data, setData] = useState(null);
|
|
@@ -246,6 +255,28 @@ export function PassageView({ id }) {
|
|
|
246
255
|
// null follows the latest point as new ones arrive; scrubbing pins it.
|
|
247
256
|
const [selectedIndex, setSelectedIndex] = useState(null);
|
|
248
257
|
const active = data?.entry.state === 'active';
|
|
258
|
+
// Only the neighbours of the passage on screen, not of the one just left.
|
|
259
|
+
const shown = data?.entry.id === id ? data.entry : null;
|
|
260
|
+
const previousId = shown?.previousEntryId ?? null;
|
|
261
|
+
const nextId = shown?.nextEntryId ?? null;
|
|
262
|
+
|
|
263
|
+
// Alt+arrow steps through the log. The key is taken even at either end of it,
|
|
264
|
+
// where it does nothing, rather than left to the browser's own Alt+left: back.
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
const onKeyDown = (event) => {
|
|
267
|
+
const direction = stepDirection(event);
|
|
268
|
+
if (direction === null) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
event.preventDefault();
|
|
272
|
+
const target = direction === 'previous' ? previousId : nextId;
|
|
273
|
+
if (target !== null && !event.repeat) {
|
|
274
|
+
location.hash = `#/passages/${target}`;
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
document.addEventListener('keydown', onKeyDown);
|
|
278
|
+
return () => document.removeEventListener('keydown', onKeyDown);
|
|
279
|
+
}, [previousId, nextId]);
|
|
249
280
|
|
|
250
281
|
// A different passage starts by following its latest point too, not
|
|
251
282
|
// wherever the scrubber was left on the previous one.
|
|
@@ -357,7 +388,23 @@ export function PassageView({ id }) {
|
|
|
357
388
|
const boat = points[scrubIndex] ?? null;
|
|
358
389
|
|
|
359
390
|
return html`
|
|
360
|
-
<
|
|
391
|
+
<div class="passage-nav">
|
|
392
|
+
<a class="back" href="#/">← ${t('passage.back')}</a>
|
|
393
|
+
<div class="passage-steps">
|
|
394
|
+
<${StepLink}
|
|
395
|
+
id=${entry.previousEntryId}
|
|
396
|
+
label=${`← ${t('passage.previous')}`}
|
|
397
|
+
shortcut="Alt+←"
|
|
398
|
+
keyName="Alt+ArrowLeft"
|
|
399
|
+
/>
|
|
400
|
+
<${StepLink}
|
|
401
|
+
id=${entry.nextEntryId}
|
|
402
|
+
label=${`${t('passage.next')} →`}
|
|
403
|
+
shortcut="Alt+→"
|
|
404
|
+
keyName="Alt+ArrowRight"
|
|
405
|
+
/>
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
361
408
|
|
|
362
409
|
<header class="passage-header">
|
|
363
410
|
<h1>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { html, useState } from '../../vendor/preact-htm.mjs';
|
|
2
2
|
import { get, request } from '../api.mjs';
|
|
3
3
|
import { useLocale, usePolling } from '../context.mjs';
|
|
4
|
+
import { DateRangePicker } from './DateRangePicker.mjs';
|
|
4
5
|
import { ErrorNotice } from './common.mjs';
|
|
5
6
|
|
|
6
7
|
const PLUGIN_CONFIGURATION = '/admin/#/serverConfiguration/plugins/signalk-chiplog';
|
|
@@ -26,19 +27,35 @@ function Progress({ progress }) {
|
|
|
26
27
|
time: `${format.shortDate(progress.now)} ${format.time(progress.now)}`
|
|
27
28
|
})}
|
|
28
29
|
</p>
|
|
30
|
+
${
|
|
31
|
+
progress.retry &&
|
|
32
|
+
html`<p class="notice">
|
|
33
|
+
${t('replay.retrying', {
|
|
34
|
+
attempt: progress.retry.attempt,
|
|
35
|
+
of: progress.retry.of,
|
|
36
|
+
message: progress.retry.message
|
|
37
|
+
})}
|
|
38
|
+
</p>`
|
|
39
|
+
}
|
|
40
|
+
${
|
|
41
|
+
progress.phase === 'replaying' &&
|
|
42
|
+
html`<${Summary} summary=${progress.summary} live=${true} />`
|
|
43
|
+
}
|
|
29
44
|
</div>
|
|
30
45
|
`;
|
|
31
46
|
}
|
|
32
47
|
|
|
33
|
-
// What
|
|
34
|
-
//
|
|
35
|
-
|
|
48
|
+
// What a run added, live while it is still going (so a timeout partway
|
|
49
|
+
// through still leaves something to show for it) and again for the final
|
|
50
|
+
// outcome -- also after a cancellation, since what was reconstructed up to
|
|
51
|
+
// that point stays on record either way.
|
|
52
|
+
function Summary({ summary, live = false }) {
|
|
36
53
|
const { t, format } = useLocale();
|
|
37
54
|
if (!summary) {
|
|
38
55
|
return null;
|
|
39
56
|
}
|
|
40
57
|
if (summary.passages === 0) {
|
|
41
|
-
return html`<p class="muted">${t('replay.summaryEmpty')}</p>`;
|
|
58
|
+
return live ? null : html`<p class="muted">${t('replay.summaryEmpty')}</p>`;
|
|
42
59
|
}
|
|
43
60
|
const facts = [
|
|
44
61
|
['replay.summaryPassages', format.count(summary.passages)],
|
|
@@ -66,12 +83,15 @@ function Outcome({ status }) {
|
|
|
66
83
|
const { t, format } = useLocale();
|
|
67
84
|
if (status.lastError) {
|
|
68
85
|
const { lastError } = status;
|
|
69
|
-
return html
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
86
|
+
return html`
|
|
87
|
+
<p class="notice notice-error">
|
|
88
|
+
${t('replay.failed', {
|
|
89
|
+
time: format.time(lastError.at),
|
|
90
|
+
message: lastError.message
|
|
91
|
+
})}
|
|
92
|
+
</p>
|
|
93
|
+
<${Summary} summary=${lastError.summary} />
|
|
94
|
+
`;
|
|
75
95
|
}
|
|
76
96
|
if (status.lastResult) {
|
|
77
97
|
const { lastResult } = status;
|
|
@@ -94,7 +114,6 @@ export function ReplayView() {
|
|
|
94
114
|
const [status, setStatus] = useState(null);
|
|
95
115
|
const [starting, setStarting] = useState(false);
|
|
96
116
|
const [error, setError] = useState(null);
|
|
97
|
-
const invalid = Boolean(from && to && to < from);
|
|
98
117
|
|
|
99
118
|
usePolling(
|
|
100
119
|
(isCurrent) => {
|
|
@@ -140,27 +159,17 @@ export function ReplayView() {
|
|
|
140
159
|
|
|
141
160
|
<section class="card">
|
|
142
161
|
<p>${t('replay.intro')}</p>
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
${t('export.to')}
|
|
155
|
-
<input
|
|
156
|
-
type="date"
|
|
157
|
-
value=${to}
|
|
158
|
-
disabled=${running}
|
|
159
|
-
onInput=${(event) => setTo(event.currentTarget.value)}
|
|
160
|
-
/>
|
|
161
|
-
</label>
|
|
162
|
-
</div>
|
|
163
|
-
${invalid && html`<p class="notice notice-error">${t('export.invalidRange')}</p>`}
|
|
162
|
+
<${DateRangePicker}
|
|
163
|
+
from=${from}
|
|
164
|
+
to=${to}
|
|
165
|
+
label=${t('range.period')}
|
|
166
|
+
allowAny=${false}
|
|
167
|
+
disabled=${running}
|
|
168
|
+
onChange=${(range) => {
|
|
169
|
+
setFrom(range.from);
|
|
170
|
+
setTo(range.to);
|
|
171
|
+
}}
|
|
172
|
+
/>
|
|
164
173
|
${
|
|
165
174
|
notConfigured &&
|
|
166
175
|
html`<p class="notice notice-error">
|
|
@@ -175,7 +184,7 @@ export function ReplayView() {
|
|
|
175
184
|
<div class="replay-actions">
|
|
176
185
|
<button
|
|
177
186
|
type="button"
|
|
178
|
-
disabled=${!from || !to ||
|
|
187
|
+
disabled=${!from || !to || starting || running}
|
|
179
188
|
onClick=${start}
|
|
180
189
|
>
|
|
181
190
|
${t('replay.start')}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { html, useEffect, useState } from '../../vendor/preact-htm.mjs';
|
|
2
|
+
import { get } from '../api.mjs';
|
|
3
|
+
import { useLocale } from '../context.mjs';
|
|
4
|
+
import {
|
|
5
|
+
activePreset,
|
|
6
|
+
countryName,
|
|
7
|
+
flagEmoji,
|
|
8
|
+
presetRange,
|
|
9
|
+
RANGE_PRESETS,
|
|
10
|
+
rangeQuery
|
|
11
|
+
} from '../statistics.mjs';
|
|
12
|
+
import { DateRangePicker } from './DateRangePicker.mjs';
|
|
13
|
+
import { ErrorNotice, Loading, passageTitle } from './common.mjs';
|
|
14
|
+
|
|
15
|
+
// The two years whose numbers label their own shortcuts; the months are words.
|
|
16
|
+
function presetLabel(preset, t, today) {
|
|
17
|
+
if (preset === 'thisYear') {
|
|
18
|
+
return String(today.getFullYear());
|
|
19
|
+
}
|
|
20
|
+
if (preset === 'lastYear') {
|
|
21
|
+
return String(today.getFullYear() - 1);
|
|
22
|
+
}
|
|
23
|
+
return t(`statistics.range.${preset}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function PeriodPicker({ from, to, onChange }) {
|
|
27
|
+
const { t } = useLocale();
|
|
28
|
+
const today = new Date();
|
|
29
|
+
const active = activePreset(from, to, today);
|
|
30
|
+
|
|
31
|
+
return html`
|
|
32
|
+
<section class="card" aria-label=${t('range.period')}>
|
|
33
|
+
<div class="presets" role="group" aria-label=${t('range.period')}>
|
|
34
|
+
${RANGE_PRESETS.map(
|
|
35
|
+
(preset) => html`
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
key=${preset}
|
|
39
|
+
class=${active === preset ? 'preset preset-on' : 'preset'}
|
|
40
|
+
aria-pressed=${active === preset}
|
|
41
|
+
onClick=${() => onChange(presetRange(preset, today))}
|
|
42
|
+
>
|
|
43
|
+
${presetLabel(preset, t, today)}
|
|
44
|
+
</button>
|
|
45
|
+
`
|
|
46
|
+
)}
|
|
47
|
+
</div>
|
|
48
|
+
<${DateRangePicker} from=${from} to=${to} onChange=${onChange} label=${t('range.period')} />
|
|
49
|
+
</section>
|
|
50
|
+
`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function Summary({ stats }) {
|
|
54
|
+
const { t, format } = useLocale();
|
|
55
|
+
const wind = format.speed(stats.maxWindSpeed);
|
|
56
|
+
const tiles = [
|
|
57
|
+
[t('statistics.count'), stats.count],
|
|
58
|
+
[t('statistics.first'), format.date(stats.firstTime)],
|
|
59
|
+
[t('statistics.last'), format.date(stats.lastTime)],
|
|
60
|
+
[t('statistics.distance'), format.distance(stats.distance)],
|
|
61
|
+
[t('statistics.duration'), format.duration(stats.duration)],
|
|
62
|
+
[t('statistics.maxSpeed'), format.speed(stats.maxSpeed) || '—'],
|
|
63
|
+
[
|
|
64
|
+
t('statistics.maxWind'),
|
|
65
|
+
wind ? (stats.maxWindApparent ? t('statistics.apparent', { speed: wind }) : wind) : '—'
|
|
66
|
+
]
|
|
67
|
+
];
|
|
68
|
+
return html`
|
|
69
|
+
<dl class="facts">
|
|
70
|
+
${tiles.map(
|
|
71
|
+
([label, value]) => html`
|
|
72
|
+
<div key=${label}>
|
|
73
|
+
<dt>${label}</dt>
|
|
74
|
+
<dd>${value}</dd>
|
|
75
|
+
</div>
|
|
76
|
+
`
|
|
77
|
+
)}
|
|
78
|
+
</dl>
|
|
79
|
+
`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function LongestNonStop({ leg }) {
|
|
83
|
+
const { t, format } = useLocale();
|
|
84
|
+
return html`
|
|
85
|
+
<section class="card">
|
|
86
|
+
<h2>${t('statistics.longest')}</h2>
|
|
87
|
+
${
|
|
88
|
+
leg
|
|
89
|
+
? html`
|
|
90
|
+
<p class="longest-facts">
|
|
91
|
+
${t('statistics.longestFacts', {
|
|
92
|
+
distance: format.distance(leg.distance),
|
|
93
|
+
duration: format.duration(leg.duration)
|
|
94
|
+
})}
|
|
95
|
+
</p>
|
|
96
|
+
<p>
|
|
97
|
+
<a href=${`#/passages/${leg.entryId}`}>${passageTitle(leg, t)}</a>
|
|
98
|
+
<span class="muted"> · ${format.date(leg.startTime)}</span>
|
|
99
|
+
</p>
|
|
100
|
+
`
|
|
101
|
+
: html`<p class="muted">${t('statistics.noData')}</p>`
|
|
102
|
+
}
|
|
103
|
+
</section>
|
|
104
|
+
`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function Countries({ countries }) {
|
|
108
|
+
const { t, language } = useLocale();
|
|
109
|
+
return html`
|
|
110
|
+
<section class="card">
|
|
111
|
+
<h2>${t('statistics.countries')}</h2>
|
|
112
|
+
${
|
|
113
|
+
countries.length > 0
|
|
114
|
+
? html`
|
|
115
|
+
<ul class="countries">
|
|
116
|
+
${countries.map(({ code }) => {
|
|
117
|
+
const name = countryName(code, language);
|
|
118
|
+
return html`
|
|
119
|
+
<li key=${code} title=${name}>
|
|
120
|
+
<span class="flag" aria-hidden="true">${flagEmoji(code) || code}</span>
|
|
121
|
+
<span>${name}</span>
|
|
122
|
+
</li>
|
|
123
|
+
`;
|
|
124
|
+
})}
|
|
125
|
+
</ul>
|
|
126
|
+
`
|
|
127
|
+
: html`<p class="muted">${t('statistics.noCountries')}</p>`
|
|
128
|
+
}
|
|
129
|
+
</section>
|
|
130
|
+
`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function Ranking({ title, passages, value }) {
|
|
134
|
+
const { t, format } = useLocale();
|
|
135
|
+
return html`
|
|
136
|
+
<section class="card">
|
|
137
|
+
<h2>${title}</h2>
|
|
138
|
+
${
|
|
139
|
+
passages.length > 0
|
|
140
|
+
? html`
|
|
141
|
+
<ol class="ranking">
|
|
142
|
+
${passages.map(
|
|
143
|
+
(passage) => html`
|
|
144
|
+
<li key=${passage.id}>
|
|
145
|
+
<a href=${`#/passages/${passage.id}`}>
|
|
146
|
+
<span class="ranking-what">
|
|
147
|
+
<span class="ranking-route">${passageTitle(passage, t)}</span>
|
|
148
|
+
<span class="muted">${format.date(passage.startTime)}</span>
|
|
149
|
+
</span>
|
|
150
|
+
<strong class="ranking-value">${value(passage)}</strong>
|
|
151
|
+
</a>
|
|
152
|
+
</li>
|
|
153
|
+
`
|
|
154
|
+
)}
|
|
155
|
+
</ol>
|
|
156
|
+
`
|
|
157
|
+
: html`<p class="muted">${t('statistics.noData')}</p>`
|
|
158
|
+
}
|
|
159
|
+
</section>
|
|
160
|
+
`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function Rankings({ top }) {
|
|
164
|
+
const { t, format } = useLocale();
|
|
165
|
+
const wind = (passage) => {
|
|
166
|
+
const speed = format.speed(passage.maxWindSpeed);
|
|
167
|
+
return passage.maxWindApparent ? t('statistics.apparent', { speed }) : speed;
|
|
168
|
+
};
|
|
169
|
+
return html`
|
|
170
|
+
<div class="card-row">
|
|
171
|
+
<${Ranking}
|
|
172
|
+
title=${t('statistics.topDuration')}
|
|
173
|
+
passages=${top.duration}
|
|
174
|
+
value=${(passage) => format.duration(passage.duration)}
|
|
175
|
+
/>
|
|
176
|
+
<${Ranking}
|
|
177
|
+
title=${t('statistics.topDistance')}
|
|
178
|
+
passages=${top.distance}
|
|
179
|
+
value=${(passage) => format.distance(passage.distance)}
|
|
180
|
+
/>
|
|
181
|
+
<${Ranking}
|
|
182
|
+
title=${t('statistics.topAverageSpeed')}
|
|
183
|
+
passages=${top.averageSpeed}
|
|
184
|
+
value=${(passage) => format.speed(passage.averageSpeed)}
|
|
185
|
+
/>
|
|
186
|
+
<${Ranking}
|
|
187
|
+
title=${t('statistics.topMaxSpeed')}
|
|
188
|
+
passages=${top.maxSpeed}
|
|
189
|
+
value=${(passage) => format.speed(passage.maxSpeed)}
|
|
190
|
+
/>
|
|
191
|
+
<${Ranking} title=${t('statistics.topMaxWind')} passages=${top.maxWindSpeed} value=${wind} />
|
|
192
|
+
</div>
|
|
193
|
+
`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function StatisticsView() {
|
|
197
|
+
const { t } = useLocale();
|
|
198
|
+
const [range, setRange] = useState({ from: '', to: '' });
|
|
199
|
+
const [stats, setStats] = useState(null);
|
|
200
|
+
const [error, setError] = useState(null);
|
|
201
|
+
const [loading, setLoading] = useState(false);
|
|
202
|
+
const [attempt, setAttempt] = useState(0);
|
|
203
|
+
|
|
204
|
+
const { from, to } = range;
|
|
205
|
+
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
let current = true;
|
|
208
|
+
const query = rangeQuery(from, to);
|
|
209
|
+
setLoading(true);
|
|
210
|
+
get(`/statistics${query ? `?${query}` : ''}`)
|
|
211
|
+
.then((next) => {
|
|
212
|
+
if (current) {
|
|
213
|
+
setStats(next);
|
|
214
|
+
setError(null);
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
.catch((failure) => current && setError(failure))
|
|
218
|
+
.finally(() => current && setLoading(false));
|
|
219
|
+
return () => {
|
|
220
|
+
current = false;
|
|
221
|
+
};
|
|
222
|
+
}, [from, to, attempt]);
|
|
223
|
+
|
|
224
|
+
return html`
|
|
225
|
+
<h1 class="page-title">${t('statistics.title')}</h1>
|
|
226
|
+
<${PeriodPicker} from=${from} to=${to} onChange=${setRange} />
|
|
227
|
+
<${ErrorNotice} error=${error} onRetry=${() => setAttempt(attempt + 1)} />
|
|
228
|
+
${!stats && !error && html`<${Loading} />`}
|
|
229
|
+
${
|
|
230
|
+
stats &&
|
|
231
|
+
html`
|
|
232
|
+
<div class=${loading ? 'statistics statistics-loading' : 'statistics'} aria-busy=${loading}>
|
|
233
|
+
${
|
|
234
|
+
stats.count === 0
|
|
235
|
+
? html`<p class="empty">${t('statistics.empty')}</p>`
|
|
236
|
+
: html`
|
|
237
|
+
<${Summary} stats=${stats} />
|
|
238
|
+
<div class="card-row">
|
|
239
|
+
<${LongestNonStop} leg=${stats.longestNonStop} />
|
|
240
|
+
<${Countries} countries=${stats.countries} />
|
|
241
|
+
</div>
|
|
242
|
+
<${Rankings} top=${stats.top} />
|
|
243
|
+
`
|
|
244
|
+
}
|
|
245
|
+
</div>
|
|
246
|
+
`
|
|
247
|
+
}
|
|
248
|
+
`;
|
|
249
|
+
}
|