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.
@@ -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
- <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>
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
- <a class="back" href="#/">← ${t('passage.back')}</a>
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 the latest run added -- also after a cancellation, since what was
34
- // reconstructed up to that point stays on record.
35
- function Summary({ summary }) {
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`<p class="notice notice-error">
70
- ${t('replay.failed', {
71
- time: format.time(lastError.at),
72
- message: lastError.message
73
- })}
74
- </p>`;
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
- <div class="date-range">
144
- <label>
145
- ${t('export.from')}
146
- <input
147
- type="date"
148
- value=${from}
149
- disabled=${running}
150
- onInput=${(event) => setFrom(event.currentTarget.value)}
151
- />
152
- </label>
153
- <label>
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 || invalid || starting || running}
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
+ }