Merge pull request #51 from laberning/frontend_redesign

Changes Architecture of the Web Frontend to use Web Components
This commit is contained in:
Lars Berning 2022-01-17 21:42:39 +01:00 committed by GitHub
commit 0c55c1ae71
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 7804 additions and 1055 deletions

21
app/client/.eslintrc.json Normal file
View File

@ -0,0 +1,21 @@
{
"env": {
"browser": true,
"node": false,
"es2021": true
},
"extends": [
"standard",
"plugin:wc/recommended",
"plugin:lit/recommended"
],
"parser": "@babel/eslint-parser",
"parserOptions": {
"ecmaVersion": 12,
"sourceType": "module"
},
"ignorePatterns": ["**/*.min.js"],
"rules": {
"camelcase": 0
}
}

View File

@ -1,169 +0,0 @@
'use strict'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
This is currently a very simple Web UI that displays the training metrics.
*/
import NoSleep from 'nosleep.js'
export function createApp () {
const fields = ['strokesTotal', 'distanceTotal', 'caloriesTotal', 'power', 'heartrate',
'splitFormatted', 'strokesPerMinute', 'durationTotalFormatted', 'peripheralMode']
const fieldFormatter = {
peripheralMode: (value) => {
if (value === 'PM5') {
return 'C2 PM5'
} else if (value === 'FTMSBIKE') {
return 'FTMS Bike'
} else {
return 'FTMS Rower'
}
},
distanceTotal: (value) => value >= 10000
? { value: (value / 1000).toFixed(1), unit: 'km' }
: { value: Math.round(value), unit: 'm' },
caloriesTotal: (value) => Math.round(value),
power: (value) => Math.round(value),
strokesPerMinute: (value) => Math.round(value)
}
const standalone = (window.location.hash === '#:standalone:')
if (standalone) {
document.getElementById('close-button').style.display = 'inline-block'
document.getElementById('fullscreen-button').style.display = 'none'
} else {
document.getElementById('fullscreen-button').style.display = 'inline-block'
document.getElementById('close-button').style.display = 'none'
}
let socket
initWebsocket()
resetFields()
requestWakeLock()
function initWebsocket () {
// use the native websocket implementation of browser to communicate with backend
// eslint-disable-next-line no-undef
socket = new WebSocket(`ws://${location.host}/websocket`)
socket.addEventListener('open', (event) => {
console.log('websocket opened')
})
socket.addEventListener('error', (error) => {
console.log('websocket error', error)
socket.close()
})
socket.addEventListener('close', (event) => {
console.log('websocket closed, attempting reconnect')
setTimeout(() => {
initWebsocket()
}, 1000)
})
socket.addEventListener('message', (event) => {
try {
const data = JSON.parse(event.data)
// show heart rate, if present
if (data.heartrate !== undefined) {
if (data.heartrate !== 0) {
document.getElementById('heartrate-container').style.display = 'inline-block'
document.getElementById('strokes-total-container').style.display = 'none'
if (data.heartrateBatteryLevel !== undefined) {
document.getElementById('heartrate-battery-container').style.display = 'inline-block'
setHeartrateMonitorBatteryLevel(data.heartrateBatteryLevel)
} else {
document.getElementById('heartrate-battery-container').style.display = 'none'
}
} else {
document.getElementById('strokes-total-container').style.display = 'inline-block'
document.getElementById('heartrate-container').style.display = 'none'
}
}
let activeFields = fields
// if we are in reset state only update heart rate and peripheral mode
if (data.strokesTotal === 0) {
activeFields = ['heartrate', 'peripheralMode']
}
for (const [key, value] of Object.entries(data)) {
if (activeFields.includes(key)) {
const valueFormatted = fieldFormatter[key] ? fieldFormatter[key](value) : value
if (valueFormatted.value !== undefined && valueFormatted.unit !== undefined) {
if (document.getElementById(key)) document.getElementById(key).innerHTML = valueFormatted.value
if (document.getElementById(`${key}Unit`)) document.getElementById(`${key}Unit`).innerHTML = valueFormatted.unit
} else {
if (document.getElementById(key)) document.getElementById(key).innerHTML = valueFormatted
}
}
}
} catch (err) {
console.log(err)
}
})
}
async function requestWakeLock () {
// Chrome enables the new Wake Lock API only if the connection is secured via SSL
// This is quite annoying for IoT use cases like this one, where the device sits on the
// local network and is directly addressed by its IP.
// In this case the only way of using SSL is by creating a self signed certificate, and
// that would pop up different warnings in the browser (and also prevents fullscreen via
// a home screen icon so it can show these warnings). Okay, enough ranting :-)
// In this case we use the good old hacky way of keeping the screen on via a hidden video.
// eslint-disable-next-line no-undef
const noSleep = new NoSleep()
document.addEventListener('click', function enableNoSleep () {
document.removeEventListener('click', enableNoSleep, false)
noSleep.enable()
}, false)
}
function resetFields () {
for (const key of fields.filter((elem) => elem !== 'peripheralMode')) {
if (document.getElementById(key)) document.getElementById(key).innerHTML = '--'
}
}
function toggleFullscreen () {
const fullscreenElement = document.getElementById('dashboard')
if (!document.fullscreenElement) {
fullscreenElement.requestFullscreen({ navigationUI: 'hide' })
} else {
if (document.exitFullscreen) {
document.exitFullscreen()
}
}
}
function close () {
window.close()
}
function reset () {
resetFields()
if (socket)socket.send(JSON.stringify({ command: 'reset' }))
}
function switchPeripheralMode () {
if (socket)socket.send(JSON.stringify({ command: 'switchPeripheralMode' }))
}
function setHeartrateMonitorBatteryLevel (batteryLevel) {
if (document.getElementById('battery-level') !== null) {
// 416 is the max width value of the battery bar in the SVG graphic
document.getElementById('battery-level').setAttribute('width', `${batteryLevel * 416 / 100}px`)
}
}
return {
toggleFullscreen,
reset,
close,
switchPeripheralMode
}
}

View File

@ -0,0 +1,41 @@
'use strict'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
Base Component for all other App Components
*/
import { LitElement } from 'lit'
import { property } from 'lit/decorators.js'
import { APP_STATE } from '../store/appState.js'
export * from 'lit'
export class AppElement extends LitElement {
// this is how we implement a global state: a global state object is passed via properties
// to child components
@property({ type: Object })
appState = APP_STATE
// ..and state changes are send back to the root component of the app by dispatching
// a CustomEvent
updateState () {
this.sendEvent('appStateChanged', this.appState)
}
// a helper to dispatch events to the parent components
sendEvent (eventType, eventData) {
this.dispatchEvent(
new CustomEvent(eventType, {
detail: eventData,
bubbles: true,
composed: true
})
)
}
// currently we do not use shadow root since there is still a global style file
// but maybe one day we dissolve it into the components and use shadow dom instead
createRenderRoot () {
return this
}
}

View File

@ -0,0 +1,85 @@
'use strict'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
Component that renders the action buttons of the dashboard
*/
import { AppElement, html, css } from './AppElement.js'
import { customElement } from 'lit/decorators.js'
import { icon_undo, icon_expand, icon_compress, icon_poweroff, icon_bluetooth } from '../lib/icons.js'
@customElement('dashboard-actions')
export class DashboardActions extends AppElement {
static get styles () {
return css`
`
}
render () {
return html`
<button @click=${this.reset}>${icon_undo}</button>
${this.renderOptionalButtons()}
<button @click=${this.switchPeripheralMode}>${icon_bluetooth}</button>
<div class="metric-unit">${this.peripheralMode()}</div>
`
}
renderOptionalButtons () {
const buttons = []
// changing to fullscreen mode only makes sence when the app is openend in a regular
// webbrowser (kiosk and standalone mode are always in fullscreen view) and the browser
// supports this feature
if (this.appState.appMode === 'BROWSER' && document.documentElement.requestFullscreen) {
buttons.push(html`
<button @click=${this.toggleFullscreen}>
<div id="fullscreen-icon">${icon_expand}</div>
<div id="windowed-icon">${icon_compress}</div>
</button>
`)
}
// a shutdown button only makes sence when the app is openend as app on a mobile
// device. at some point we might also think of using this to power down the raspi
// when we are running in kiosk mode
if (this.appState.appMode === 'STANDALONE') {
buttons.push(html`
<button @click=${this.close} id="close-button">${icon_poweroff}</button>
`)
}
return buttons
}
peripheralMode () {
const value = this.appState?.peripheralMode
if (value === 'PM5') {
return 'C2 PM5'
} else if (value === 'FTMSBIKE') {
return 'FTMS Bike'
} else {
return 'FTMS Rower'
}
}
toggleFullscreen () {
const fullscreenElement = document.getElementsByTagName('web-app')[0]
if (!document.fullscreenElement) {
fullscreenElement.requestFullscreen({ navigationUI: 'hide' })
} else {
if (document.exitFullscreen) {
document.exitFullscreen()
}
}
}
close () {
window.close()
}
reset () {
this.sendEvent('triggerAction', { command: 'reset' })
}
switchPeripheralMode () {
this.sendEvent('triggerAction', { command: 'switchPeripheralMode' })
}
}

View File

@ -0,0 +1,55 @@
'use strict'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
Component that renders a metric of the dashboard
*/
import { AppElement, html, svg, css } from './AppElement.js'
import { customElement, property } from 'lit/decorators.js'
@customElement('dashboard-metric')
export class DashboardMetric extends AppElement {
static get styles () {
return css`
`
}
@property({ type: Object })
icon
@property({ type: String })
unit = ''
@property({ type: String })
value = ''
@property({ type: String })
batteryLevel = ''
render () {
return html`
<div class="label">${this.icon}</div>
<div class="content">
<span class="metric-value">${this.value !== undefined ? this.value : '--'}</span>
<span class="metric-unit">${this.unit}</span>
</div>
${this.batteryLevel &&
html`<div id="heartrate-battery-container">${this.batteryIcon}</div>`
}
`
}
get batteryIcon () {
// 416 is the max width value of the battery bar in the SVG graphic
const batteryWidth = this.batteryLevel * 416 / 100
return svg`
<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512">
<path fill="currentColor" d="M544 160v64h32v64h-32v64H64V160h480m16-64H48c-26.51 0-48 21.49-48 48v224c0 26.51 21.49 48 48 48h512c26.51 0 48-21.49 48-48v-16h8c13.255 0 24-10.745 24-24V184c0-13.255-10.745-24-24-24h-8v-16c0-26.51-21.49-48-48-48z"></path>
<rect fill="currentColor" id="battery-level" x="96" y="192" width=${batteryWidth} height="128"></rect>
</svg>
`
}
}

View File

@ -0,0 +1,72 @@
'use strict'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
Component that renders the dashboard
*/
import { AppElement, html, css } from './AppElement.js'
import { APP_STATE } from '../store/appState.js'
import { customElement, property } from 'lit/decorators.js'
import './DashboardMetric.js'
import './DashboardActions.js'
import { icon_route, icon_stopwatch, icon_bolt, icon_paddle, icon_heartbeat, icon_fire, icon_clock } from '../lib/icons.js'
@customElement('performance-dashboard')
export class PerformanceDashboard extends AppElement {
static get styles () {
return css`
`
}
@property({ type: Object })
metrics
@property({ type: Object })
appState = APP_STATE
render () {
const metrics = this.calculateFormattedMetrics(this.appState.metrics)
return html`
<dashboard-metric .icon=${icon_route} .unit=${metrics?.distanceTotal?.unit || 'm'} .value=${metrics?.distanceTotal?.value}></dashboard-metric>
<dashboard-metric .icon=${icon_stopwatch} unit="/500m" .value=${metrics?.splitFormatted?.value}></dashboard-metric>
<dashboard-metric .icon=${icon_bolt} unit="watt" .value=${metrics?.power?.value}></dashboard-metric>
<dashboard-metric .icon=${icon_paddle} unit="/min" .value=${metrics?.strokesPerMinute?.value}></dashboard-metric>
${metrics?.heartrate?.value
? html`<dashboard-metric .icon=${icon_heartbeat} unit="bpm"
.value=${metrics?.heartrate?.value}
.batteryLevel=${metrics?.heartrateBatteryLevel?.value}></dashboard-metric>`
: html`<dashboard-metric .icon=${icon_paddle} unit="total" .value=${metrics?.strokesTotal?.value}></dashboard-metric>`}
<dashboard-metric .icon=${icon_fire} unit="kcal" .value=${metrics?.caloriesTotal?.value}></dashboard-metric>
<dashboard-metric .icon=${icon_clock} .value=${metrics?.durationTotalFormatted?.value}></dashboard-metric>
<dashboard-actions .appState=${this.appState}></dashboard-actions>
`
}
calculateFormattedMetrics (metrics) {
const fieldFormatter = {
distanceTotal: (value) => value >= 10000
? { value: (value / 1000).toFixed(1), unit: 'km' }
: { value: Math.round(value), unit: 'm' },
caloriesTotal: (value) => Math.round(value),
power: (value) => Math.round(value),
strokesPerMinute: (value) => Math.round(value)
}
const formattedMetrics = {}
for (const [key, value] of Object.entries(metrics)) {
const valueFormatted = fieldFormatter[key] ? fieldFormatter[key](value) : value
if (valueFormatted.value !== undefined && valueFormatted.unit !== undefined) {
formattedMetrics[key] = {
value: valueFormatted.value,
unit: valueFormatted.unit
}
} else {
formattedMetrics[key] = {
value: valueFormatted
}
}
}
return formattedMetrics
}
}

View File

@ -15,132 +15,8 @@
<title>Open Rowing Monitor</title>
</head>
<body>
<script type="module" src="/index.js"></script>
<div id="dashboard" class="grid">
<div class="col">
<div class="label">
<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M416 320h-96c-17.6 0-32-14.4-32-32s14.4-32 32-32h96s96-107 96-160-43-96-96-96-96 43-96 96c0 25.5 22.2 63.4 45.3 96H320c-52.9 0-96 43.1-96 96s43.1 96 96 96h96c17.6 0 32 14.4 32 32s-14.4 32-32 32H185.5c-16 24.8-33.8 47.7-47.3 64H416c52.9 0 96-43.1 96-96s-43.1-96-96-96zm0-256c17.7 0 32 14.3 32 32s-14.3 32-32 32-32-14.3-32-32 14.3-32 32-32zM96 256c-53 0-96 43-96 96s96 160 96 160 96-107 96-160-43-96-96-96zm0 128c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z"></path></svg>
</div>
<div class="content">
<span class="metric-value" id="distanceTotal"></span>
<span class="metric-unit" id="distanceTotalUnit">m</span>
</div>
</div>
<div class="col">
<div class="label">
<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M432 304c0 114.9-93.1 208-208 208S16 418.9 16 304c0-104 76.3-190.2 176-205.5V64h-28c-6.6 0-12-5.4-12-12V12c0-6.6 5.4-12 12-12h120c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12h-28v34.5c37.5 5.8 71.7 21.6 99.7 44.6l27.5-27.5c4.7-4.7 12.3-4.7 17 0l28.3 28.3c4.7 4.7 4.7 12.3 0 17l-29.4 29.4-.6.6C419.7 223.3 432 262.2 432 304zm-176 36V188.5c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12V340c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12z"></path></svg>
</div>
<div class="content">
<span class="metric-value" id="splitFormatted"></span>
<span class="metric-unit">/500m</span>
</div>
</div>
<div class="col">
<div class="label">
<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path fill="currentColor" d="M296 160H180.6l42.6-129.8C227.2 15 215.7 0 200 0H56C44 0 33.8 8.9 32.2 20.8l-32 240C-1.7 275.2 9.5 288 24 288h118.7L96.6 482.5c-3.6 15.2 8 29.5 23.3 29.5 8.4 0 16.4-4.4 20.8-12l176-304c9.3-15.9-2.2-36-20.7-36z"></path></svg>
</div>
<div class="content">
<span class="metric-value" id="power"></span>
<span class="metric-unit">watt</span>
</div>
</div>
<div class="col">
<div class="label">
<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path fill="currentColor" d="m 420.20605,468.95387 33.11371,-33.1137 c 5.22683,-5.22684 8.77492,-17.24658 3.54807,-22.47345 l -2.40714,-2.40718 c -5.22685,-5.22685 -24.05986,3.18856 -30.46144,6.8845 L 208.43041,202.27519 C 197.48359,161.4211 171.36396,110.7189 139.6182,78.964771 L 104.97336,44.31993 C 84.074343,23.420923 78.962098,22.904081 62.197813,39.66838 L 24.014429,77.851751 C 9.0221901,92.843993 7.7868003,99.708469 28.685812,120.60748 l 34.653205,34.65321 c 30.664056,30.66402 82.456343,57.8654 123.310393,68.8122 l 215.56885,215.56886 c -3.69593,6.40155 -12.11135,25.23463 -6.88448,30.46145 l 2.40718,2.40715 c 5.2185,5.21846 17.23824,1.67039 22.46509,-3.55648 z"/>
<path fill="currentColor" d="M 93.111861,469.41843 59.998156,436.30471 c -5.226842,-5.22684 -8.774914,-17.24659 -3.548088,-22.47344 l 2.407166,-2.40717 c 5.226836,-5.22685 24.059868,3.18854 30.46142,6.88449 L 304.88751,202.73974 c 10.94682,-40.85409 37.06645,-91.55629 68.81223,-123.310429 L 408.34455,44.78448 c 20.89903,-20.899007 26.01127,-21.415839 42.77554,-4.65156 l 38.18338,38.183384 c 14.99224,14.992241 16.22764,21.856706 -4.67138,42.755726 l -34.65319,34.6532 c -30.66405,30.66404 -82.45634,57.8654 -123.31038,68.8122 L 111.09965,440.10631 c 3.69593,6.40153 12.11135,25.23461 6.88448,30.46143 l -2.40718,2.40716 c -5.21849,5.21846 -17.23824,1.67038 -22.465089,-3.55647 z"/>
</svg>
</div>
<div class="content">
<span class="metric-value" id="strokesPerMinute"></span>
<span class="metric-unit">/min</span>
</div>
</div>
<div class="col">
<div id="strokes-total-container">
<div class="label">
<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path fill="currentColor" d="m 420.20605,468.95387 33.11371,-33.1137 c 5.22683,-5.22684 8.77492,-17.24658 3.54807,-22.47345 l -2.40714,-2.40718 c -5.22685,-5.22685 -24.05986,3.18856 -30.46144,6.8845 L 208.43041,202.27519 C 197.48359,161.4211 171.36396,110.7189 139.6182,78.964771 L 104.97336,44.31993 C 84.074343,23.420923 78.962098,22.904081 62.197813,39.66838 L 24.014429,77.851751 C 9.0221901,92.843993 7.7868003,99.708469 28.685812,120.60748 l 34.653205,34.65321 c 30.664056,30.66402 82.456343,57.8654 123.310393,68.8122 l 215.56885,215.56886 c -3.69593,6.40155 -12.11135,25.23463 -6.88448,30.46145 l 2.40718,2.40715 c 5.2185,5.21846 17.23824,1.67039 22.46509,-3.55648 z"/>
<path fill="currentColor" d="M 93.111861,469.41843 59.998156,436.30471 c -5.226842,-5.22684 -8.774914,-17.24659 -3.548088,-22.47344 l 2.407166,-2.40717 c 5.226836,-5.22685 24.059868,3.18854 30.46142,6.88449 L 304.88751,202.73974 c 10.94682,-40.85409 37.06645,-91.55629 68.81223,-123.310429 L 408.34455,44.78448 c 20.89903,-20.899007 26.01127,-21.415839 42.77554,-4.65156 l 38.18338,38.183384 c 14.99224,14.992241 16.22764,21.856706 -4.67138,42.755726 l -34.65319,34.6532 c -30.66405,30.66404 -82.45634,57.8654 -123.31038,68.8122 L 111.09965,440.10631 c 3.69593,6.40153 12.11135,25.23461 6.88448,30.46143 l -2.40718,2.40716 c -5.21849,5.21846 -17.23824,1.67038 -22.465089,-3.55647 z"/>
</svg>
</div>
<div class="content">
<span class="metric-value" id="strokesTotal"></span>
<span class="metric-unit">total</span>
</div>
</div>
<div id="heartrate-container">
<div class="label">
<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path fill="currentColor" d="M320.2 243.8l-49.7 99.4c-6 12.1-23.4 11.7-28.9-.6l-56.9-126.3-30 71.7H60.6l182.5 186.5c7.1 7.3 18.6 7.3 25.7 0L451.4 288H342.3l-22.1-44.2zM473.7 73.9l-2.4-2.5c-51.5-52.6-135.8-52.6-187.4 0L256 100l-27.9-28.5c-51.5-52.7-135.9-52.7-187.4 0l-2.4 2.4C-10.4 123.7-12.5 203 31 256h102.4l35.9-86.2c5.4-12.9 23.6-13.2 29.4-.4l58.2 129.3 49-97.9c5.9-11.8 22.7-11.8 28.6 0l27.6 55.2H481c43.5-53 41.4-132.3-7.3-182.1z"></path>
</svg>
</div>
<div class="content">
<span class="metric-value" id="heartrate"></span>
<span class="metric-unit">bpm</span>
</div>
<div id="heartrate-battery-container">
<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512">
<path fill="currentColor" d="M544 160v64h32v64h-32v64H64V160h480m16-64H48c-26.51 0-48 21.49-48 48v224c0 26.51 21.49 48 48 48h512c26.51 0 48-21.49 48-48v-16h8c13.255 0 24-10.745 24-24V184c0-13.255-10.745-24-24-24h-8v-16c0-26.51-21.49-48-48-48z"></path>
<rect fill="currentColor" id="battery-level" x="96" y="192" width="416" height="128"></rect>
</svg>
</div>
</div>
</div>
<div class="col">
<div class="label">
<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M323.56 51.2c-20.8 19.3-39.58 39.59-56.22 59.97C240.08 73.62 206.28 35.53 168 0 69.74 91.17 0 209.96 0 281.6 0 408.85 100.29 512 224 512s224-103.15 224-230.4c0-53.27-51.98-163.14-124.44-230.4zm-19.47 340.65C282.43 407.01 255.72 416 226.86 416 154.71 416 96 368.26 96 290.75c0-38.61 24.31-72.63 72.79-130.75 6.93 7.98 98.83 125.34 98.83 125.34l58.63-66.88c4.14 6.85 7.91 13.55 11.27 19.97 27.35 52.19 15.81 118.97-33.43 153.42z"></path></svg>
</div>
<div class="content">
<span class="metric-value" id="caloriesTotal"></span>
<span class="metric-unit">kcal</span>
</div>
</div>
<div class="col">
<div class="label">
<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M256,8C119,8,8,119,8,256S119,504,256,504,504,393,504,256,393,8,256,8Zm92.49,313h0l-20,25a16,16,0,0,1-22.49,2.5h0l-67-49.72a40,40,0,0,1-15-31.23V112a16,16,0,0,1,16-16h32a16,16,0,0,1,16,16V256l58,42.5A16,16,0,0,1,348.49,321Z"></path></svg>
</div>
<div class="content">
<span class="metric-value" id="durationTotalFormatted"></span>
<span class="metric-unit"></span>
</div>
</div>
<div class="col actions">
<div class="content">
<button onclick="app.reset()">
<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path fill="currentColor" d="M212.333 224.333H12c-6.627 0-12-5.373-12-12V12C0 5.373 5.373 0 12 0h48c6.627 0 12 5.373 12 12v78.112C117.773 39.279 184.26 7.47 258.175 8.007c136.906.994 246.448 111.623 246.157 248.532C504.041 393.258 393.12 504 256.333 504c-64.089 0-122.496-24.313-166.51-64.215-5.099-4.622-5.334-12.554-.467-17.42l33.967-33.967c4.474-4.474 11.662-4.717 16.401-.525C170.76 415.336 211.58 432 256.333 432c97.268 0 176-78.716 176-176 0-97.267-78.716-176-176-176-58.496 0-110.28 28.476-142.274 72.333h98.274c6.627 0 12 5.373 12 12v48c0 6.627-5.373 12-12 12z"></path>
</svg>
</button>
<button onclick="app.toggleFullscreen()" id="fullscreen-button">
<div id="fullscreen-icon">
<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
<path fill="currentColor" d="M0 180V56c0-13.3 10.7-24 24-24h124c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12H64v84c0 6.6-5.4 12-12 12H12c-6.6 0-12-5.4-12-12zM288 44v40c0 6.6 5.4 12 12 12h84v84c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12V56c0-13.3-10.7-24-24-24H300c-6.6 0-12 5.4-12 12zm148 276h-40c-6.6 0-12 5.4-12 12v84h-84c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h124c13.3 0 24-10.7 24-24V332c0-6.6-5.4-12-12-12zM160 468v-40c0-6.6-5.4-12-12-12H64v-84c0-6.6-5.4-12-12-12H12c-6.6 0-12 5.4-12 12v124c0 13.3 10.7 24 24 24h124c6.6 0 12-5.4 12-12z"></path>
</svg>
</div>
<div id="windowed-icon">
<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
<path fill="currentColor" d="M436 192H312c-13.3 0-24-10.7-24-24V44c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v84h84c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12zm-276-24V44c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v84H12c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h124c13.3 0 24-10.7 24-24zm0 300V344c0-13.3-10.7-24-24-24H12c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h84v84c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12zm192 0v-84h84c6.6 0 12-5.4 12-12v-40c0-6.6-5.4-12-12-12H312c-13.3 0-24 10.7-24 24v124c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12z"></path>
</svg>
</div>
</button>
<button onclick="app.close()" id="close-button">
<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path fill="currentColor" d="M400 54.1c63 45 104 118.6 104 201.9 0 136.8-110.8 247.7-247.5 248C120 504.3 8.2 393 8 256.4 7.9 173.1 48.9 99.3 111.8 54.2c11.7-8.3 28-4.8 35 7.7L162.6 90c5.9 10.5 3.1 23.8-6.6 31-41.5 30.8-68 79.6-68 134.9-.1 92.3 74.5 168.1 168 168.1 91.6 0 168.6-74.2 168-169.1-.3-51.8-24.7-101.8-68.1-134-9.7-7.2-12.4-20.5-6.5-30.9l15.8-28.1c7-12.4 23.2-16.1 34.8-7.8zM296 264V24c0-13.3-10.7-24-24-24h-32c-13.3 0-24 10.7-24 24v240c0 13.3 10.7 24 24 24h32c13.3 0 24-10.7 24-24z"></path>
</svg>
</button>
<button onclick="app.switchPeripheralMode()">
<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512">
<path fill="currentColor" d="M292.6 171.1L249.7 214l-.3-86 43.2 43.1m-43.2 219.8l43.1-43.1-42.9-42.9-.2 86zM416 259.4C416 465 344.1 512 230.9 512S32 465 32 259.4 115.4 0 228.6 0 416 53.9 416 259.4zm-158.5 0l79.4-88.6L211.8 36.5v176.9L138 139.6l-27 26.9 92.7 93-92.7 93 26.9 26.9 73.8-73.8 2.3 170 127.4-127.5-83.9-88.7z"></path>
</svg>
</button>
<div class="metric-unit" id="peripheralMode"></div>
</div>
</div>
</div>
<noscript>You need to enable JavaScript to run this app.</noscript>
<web-app></web-app>
<script type="module" src="index.js"></script>
</body>
</html>

View File

@ -2,8 +2,72 @@
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
Init file for the web frontend
Main Initialization Component of the Web Component App
*/
import { createApp } from './app.js'
window.app = createApp()
import { LitElement, html } from 'lit'
import { customElement, state } from 'lit/decorators.js'
import { APP_STATE } from './store/appState.js'
import { createApp } from './lib/app.js'
import './components/PerformanceDashboard.js'
@customElement('web-app')
export class App extends LitElement {
@state()
appState = APP_STATE
@state()
metrics
constructor () {
super()
this.app = createApp({
updateState: this.updateState,
getState: this.getState
// todo: we also want a mechanism here to get notified of state changes
})
// this is how we implement changes to the global state:
// once any child component sends this CustomEvent we update the global state according
// to the changes that were passed to us
this.addEventListener('appStateChanged', (event) => {
this.updateState(event.detail)
})
// notify the app about the triggered action
this.addEventListener('triggerAction', (event) => {
this.app.handleAction(event.detail)
})
}
// the global state is updated by replacing the appState with a copy of the new state
// todo: maybe it is more convenient to just pass the state elements that should be changed?
// i.e. do something like this.appState = { ..this.appState, ...newState }
updateState = (newState) => {
this.appState = { ...newState }
}
// return a deep copy of the state to other components to minimize risk of side effects
getState = () => {
// could use structuredClone once the browser support is wider
// https://developer.mozilla.org/en-US/docs/Web/API/structuredClone
return JSON.parse(JSON.stringify(this.appState))
}
// once we have multiple views, then we would rather reference some kind of router here
// instead of embedding the performance-dashboard directly
render () {
return html`
<performance-dashboard
.appState=${this.appState}
.metrics=${this.metrics}
></performance-dashboard>
`
}
// there is no need to put this initialization component into a shadow root
createRenderRoot () {
return this
}
}

106
app/client/lib/app.js Normal file
View File

@ -0,0 +1,106 @@
'use strict'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
Initialization file of the Open Rowing Monitor App
*/
import NoSleep from 'nosleep.js'
import { filterObjectByKeys } from './helper.js'
const rowingMetricsFields = ['strokesTotal', 'distanceTotal', 'caloriesTotal', 'power', 'heartrate',
'heartrateBatteryLevel', 'splitFormatted', 'strokesPerMinute', 'durationTotalFormatted']
export function createApp (app) {
const mode = window.location.hash
const appMode = mode === '#:standalone:' ? 'STANDALONE' : mode === '#:kiosk:' ? 'KIOSK' : 'BROWSER'
app.updateState({ ...app.getState(), appMode })
let socket
initWebsocket()
resetFields()
requestWakeLock()
function initWebsocket () {
// use the native websocket implementation of browser to communicate with backend
// eslint-disable-next-line no-undef
socket = new WebSocket(`ws://${location.host}/websocket`)
socket.addEventListener('open', (event) => {
console.log('websocket opened')
})
socket.addEventListener('error', (error) => {
console.log('websocket error', error)
socket.close()
})
socket.addEventListener('close', (event) => {
console.log('websocket closed, attempting reconnect')
setTimeout(() => {
initWebsocket()
}, 1000)
})
// todo: we should use different types of messages to make processing easier
socket.addEventListener('message', (event) => {
try {
const data = JSON.parse(event.data)
let activeFields = rowingMetricsFields
// if we are in reset state only update heart rate
if (data.strokesTotal === 0) {
activeFields = ['heartrate', 'heartrateBatteryLevel']
}
const filteredData = filterObjectByKeys(data, activeFields)
let updatedState = { ...app.getState(), metrics: filteredData }
if (data.peripheralMode) {
updatedState = { ...app.getState(), peripheralMode: data.peripheralMode }
}
app.updateState(updatedState)
} catch (err) {
console.log(err)
}
})
}
async function requestWakeLock () {
// Chrome enables the new Wake Lock API only if the connection is secured via SSL
// This is quite annoying for IoT use cases like this one, where the device sits on the
// local network and is directly addressed by its IP.
// In this case the only way of using SSL is by creating a self signed certificate, and
// that would pop up different warnings in the browser (and also prevents fullscreen via
// a home screen icon so it can show these warnings). Okay, enough ranting :-)
// In this case we use the good old hacky way of keeping the screen on via a hidden video.
const noSleep = new NoSleep()
document.addEventListener('click', function enableNoSleep () {
document.removeEventListener('click', enableNoSleep, false)
noSleep.enable()
}, false)
}
function resetFields () {
const appState = app.getState()
// drop all metrics except heartrate
appState.metrics = filterObjectByKeys(appState.metrics, ['heartrate', 'heartrateBatteryLevel'])
app.updateState(appState)
}
function handleAction (action) {
if (action.command === 'switchPeripheralMode') {
if (socket)socket.send(JSON.stringify({ command: 'switchPeripheralMode' }))
} else if (action.command === 'reset') {
resetFields()
if (socket)socket.send(JSON.stringify({ command: 'reset' }))
} else {
console.error('no handler defined for action', action)
}
}
return {
handleAction
}
}

16
app/client/lib/helper.js Normal file
View File

@ -0,0 +1,16 @@
'use strict'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
Helper functions
*/
// Filters an object so that it only contains the attributes that are defined in a list
export function filterObjectByKeys (object, keys) {
return Object.keys(object)
.filter(key => keys.includes(key))
.reduce((obj, key) => {
obj[key] = object[key]
return obj
}, {})
}

View File

@ -0,0 +1,24 @@
'use strict'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
*/
import { test } from 'uvu'
import * as assert from 'uvu/assert'
import { filterObjectByKeys } from './helper.js'
test('filterd list should only contain the elements specified', () => {
const object1 = {
a: ['a1', 'a2'],
b: 'b'
}
const object2 = {
a: ['a1', 'a2']
}
const filteredObject = filterObjectByKeys(object1, ['a'])
assert.equal(filterObjectByKeys(filteredObject, ['a']), object2)
})
test.run()

26
app/client/lib/icons.js Normal file
View File

@ -0,0 +1,26 @@
'use strict'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
SVG Icons that are used by the Application
*/
import { svg } from 'lit'
export const icon_route = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M416 320h-96c-17.6 0-32-14.4-32-32s14.4-32 32-32h96s96-107 96-160-43-96-96-96-96 43-96 96c0 25.5 22.2 63.4 45.3 96H320c-52.9 0-96 43.1-96 96s43.1 96 96 96h96c17.6 0 32 14.4 32 32s-14.4 32-32 32H185.5c-16 24.8-33.8 47.7-47.3 64H416c52.9 0 96-43.1 96-96s-43.1-96-96-96zm0-256c17.7 0 32 14.3 32 32s-14.3 32-32 32-32-14.3-32-32 14.3-32 32-32zM96 256c-53 0-96 43-96 96s96 160 96 160 96-107 96-160-43-96-96-96zm0 128c-17.7 0-32-14.3-32-32s14.3-32 32-32 32 14.3 32 32-14.3 32-32 32z"></path></svg>`
export const icon_stopwatch = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M432 304c0 114.9-93.1 208-208 208S16 418.9 16 304c0-104 76.3-190.2 176-205.5V64h-28c-6.6 0-12-5.4-12-12V12c0-6.6 5.4-12 12-12h120c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12h-28v34.5c37.5 5.8 71.7 21.6 99.7 44.6l27.5-27.5c4.7-4.7 12.3-4.7 17 0l28.3 28.3c4.7 4.7 4.7 12.3 0 17l-29.4 29.4-.6.6C419.7 223.3 432 262.2 432 304zm-176 36V188.5c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12V340c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12z"></path></svg>`
export const icon_bolt = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512"><path fill="currentColor" d="M296 160H180.6l42.6-129.8C227.2 15 215.7 0 200 0H56C44 0 33.8 8.9 32.2 20.8l-32 240C-1.7 275.2 9.5 288 24 288h118.7L96.6 482.5c-3.6 15.2 8 29.5 23.3 29.5 8.4 0 16.4-4.4 20.8-12l176-304c9.3-15.9-2.2-36-20.7-36z"></path></svg>`
export const icon_paddle = svg`
<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path fill="currentColor" d="m 420.20605,468.95387 33.11371,-33.1137 c 5.22683,-5.22684 8.77492,-17.24658 3.54807,-22.47345 l -2.40714,-2.40718 c -5.22685,-5.22685 -24.05986,3.18856 -30.46144,6.8845 L 208.43041,202.27519 C 197.48359,161.4211 171.36396,110.7189 139.6182,78.964771 L 104.97336,44.31993 C 84.074343,23.420923 78.962098,22.904081 62.197813,39.66838 L 24.014429,77.851751 C 9.0221901,92.843993 7.7868003,99.708469 28.685812,120.60748 l 34.653205,34.65321 c 30.664056,30.66402 82.456343,57.8654 123.310393,68.8122 l 215.56885,215.56886 c -3.69593,6.40155 -12.11135,25.23463 -6.88448,30.46145 l 2.40718,2.40715 c 5.2185,5.21846 17.23824,1.67039 22.46509,-3.55648 z"/>
<path fill="currentColor" d="M 93.111861,469.41843 59.998156,436.30471 c -5.226842,-5.22684 -8.774914,-17.24659 -3.548088,-22.47344 l 2.407166,-2.40717 c 5.226836,-5.22685 24.059868,3.18854 30.46142,6.88449 L 304.88751,202.73974 c 10.94682,-40.85409 37.06645,-91.55629 68.81223,-123.310429 L 408.34455,44.78448 c 20.89903,-20.899007 26.01127,-21.415839 42.77554,-4.65156 l 38.18338,38.183384 c 14.99224,14.992241 16.22764,21.856706 -4.67138,42.755726 l -34.65319,34.6532 c -30.66405,30.66404 -82.45634,57.8654 -123.31038,68.8122 L 111.09965,440.10631 c 3.69593,6.40153 12.11135,25.23461 6.88448,30.46143 l -2.40718,2.40716 c -5.21849,5.21846 -17.23824,1.67038 -22.465089,-3.55647 z"/>
</svg>
`
export const icon_heartbeat = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M320.2 243.8l-49.7 99.4c-6 12.1-23.4 11.7-28.9-.6l-56.9-126.3-30 71.7H60.6l182.5 186.5c7.1 7.3 18.6 7.3 25.7 0L451.4 288H342.3l-22.1-44.2zM473.7 73.9l-2.4-2.5c-51.5-52.6-135.8-52.6-187.4 0L256 100l-27.9-28.5c-51.5-52.7-135.9-52.7-187.4 0l-2.4 2.4C-10.4 123.7-12.5 203 31 256h102.4l35.9-86.2c5.4-12.9 23.6-13.2 29.4-.4l58.2 129.3 49-97.9c5.9-11.8 22.7-11.8 28.6 0l27.6 55.2H481c43.5-53 41.4-132.3-7.3-182.1z"></path></svg>`
export const icon_fire = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M323.56 51.2c-20.8 19.3-39.58 39.59-56.22 59.97C240.08 73.62 206.28 35.53 168 0 69.74 91.17 0 209.96 0 281.6 0 408.85 100.29 512 224 512s224-103.15 224-230.4c0-53.27-51.98-163.14-124.44-230.4zm-19.47 340.65C282.43 407.01 255.72 416 226.86 416 154.71 416 96 368.26 96 290.75c0-38.61 24.31-72.63 72.79-130.75 6.93 7.98 98.83 125.34 98.83 125.34l58.63-66.88c4.14 6.85 7.91 13.55 11.27 19.97 27.35 52.19 15.81 118.97-33.43 153.42z"></path></svg>`
export const icon_clock = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M256,8C119,8,8,119,8,256S119,504,256,504,504,393,504,256,393,8,256,8Zm92.49,313h0l-20,25a16,16,0,0,1-22.49,2.5h0l-67-49.72a40,40,0,0,1-15-31.23V112a16,16,0,0,1,16-16h32a16,16,0,0,1,16,16V256l58,42.5A16,16,0,0,1,348.49,321Z"></path></svg>`
export const icon_undo = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M212.333 224.333H12c-6.627 0-12-5.373-12-12V12C0 5.373 5.373 0 12 0h48c6.627 0 12 5.373 12 12v78.112C117.773 39.279 184.26 7.47 258.175 8.007c136.906.994 246.448 111.623 246.157 248.532C504.041 393.258 393.12 504 256.333 504c-64.089 0-122.496-24.313-166.51-64.215-5.099-4.622-5.334-12.554-.467-17.42l33.967-33.967c4.474-4.474 11.662-4.717 16.401-.525C170.76 415.336 211.58 432 256.333 432c97.268 0 176-78.716 176-176 0-97.267-78.716-176-176-176-58.496 0-110.28 28.476-142.274 72.333h98.274c6.627 0 12 5.373 12 12v48c0 6.627-5.373 12-12 12z"></path></svg>`
export const icon_poweroff = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M400 54.1c63 45 104 118.6 104 201.9 0 136.8-110.8 247.7-247.5 248C120 504.3 8.2 393 8 256.4 7.9 173.1 48.9 99.3 111.8 54.2c11.7-8.3 28-4.8 35 7.7L162.6 90c5.9 10.5 3.1 23.8-6.6 31-41.5 30.8-68 79.6-68 134.9-.1 92.3 74.5 168.1 168 168.1 91.6 0 168.6-74.2 168-169.1-.3-51.8-24.7-101.8-68.1-134-9.7-7.2-12.4-20.5-6.5-30.9l15.8-28.1c7-12.4 23.2-16.1 34.8-7.8zM296 264V24c0-13.3-10.7-24-24-24h-32c-13.3 0-24 10.7-24 24v240c0 13.3 10.7 24 24 24h32c13.3 0 24-10.7 24-24z"></path></svg>`
export const icon_expand = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M0 180V56c0-13.3 10.7-24 24-24h124c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12H64v84c0 6.6-5.4 12-12 12H12c-6.6 0-12-5.4-12-12zM288 44v40c0 6.6 5.4 12 12 12h84v84c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12V56c0-13.3-10.7-24-24-24H300c-6.6 0-12 5.4-12 12zm148 276h-40c-6.6 0-12 5.4-12 12v84h-84c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h124c13.3 0 24-10.7 24-24V332c0-6.6-5.4-12-12-12zM160 468v-40c0-6.6-5.4-12-12-12H64v-84c0-6.6-5.4-12-12-12H12c-6.6 0-12 5.4-12 12v124c0 13.3 10.7 24 24 24h124c6.6 0 12-5.4 12-12z"></path></svg>`
export const icon_compress = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M436 192H312c-13.3 0-24-10.7-24-24V44c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v84h84c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12zm-276-24V44c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v84H12c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h124c13.3 0 24-10.7 24-24zm0 300V344c0-13.3-10.7-24-24-24H12c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h84v84c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12zm192 0v-84h84c6.6 0 12-5.4 12-12v-40c0-6.6-5.4-12-12-12H312c-13.3 0-24 10.7-24 24v124c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12z"></path></svg>`
export const icon_bluetooth = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M292.6 171.1L249.7 214l-.3-86 43.2 43.1m-43.2 219.8l43.1-43.1-42.9-42.9-.2 86zM416 259.4C416 465 344.1 512 230.9 512S32 465 32 259.4 115.4 0 228.6 0 416 53.9 416 259.4zm-158.5 0l79.4-88.6L211.8 36.5v176.9L138 139.6l-27 26.9 92.7 93-92.7 93 26.9 26.9 73.8-73.8 2.3 170 127.4-127.5-83.9-88.7z"></path></svg>`

View File

@ -0,0 +1,15 @@
'use strict'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
Defines the global state of the app
*/
export const APP_STATE = {
// currently can be STANDALONE (Mobile Home Screen App), KIOSK (Raspberry Pi deployment) or '' (default)
appMode: '',
// currently can be FTMS, FTMSBIKE or PM5
peripheralMode: 'FTMS',
// contains all the rowing metrics that are delivered from the backend
metrics: {}
}

View File

@ -11,7 +11,7 @@ body {
overscroll-behavior: contain;
}
.grid {
performance-dashboard {
display: grid;
height: calc(100vh - 2vw);
padding: 1vw;
@ -20,18 +20,19 @@ body {
grid-template-rows: repeat(2, minmax(0, 1fr));
}
.col {
dashboard-metric, dashboard-actions {
background: #002b57;
text-align: center;
position: relative;
padding: 0.5em 0.2em 0 0.2em;
}
.col.actions {
dashboard-actions {
padding: 0.5em 0 0 0;
}
@media (orientation: portrait) {
.grid {
performance-dashboard {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: repeat(4, minmax(0, 1fr));
}
@ -70,7 +71,6 @@ div.label, div.content {
right: 0.2em;
bottom: 0;
position: absolute;
display: none;
}
.metric-value {
font-size: 150%;
@ -93,13 +93,3 @@ button {
display: inline-block;
width: 3.5em;
}
#close-button, #heartrate-container {
display: none;
}
#heartrate-container, #strokes-total-container {
width: 100%;
height: 100%;
position: relative;
}

View File

@ -26,7 +26,7 @@ for (const [loggerName, logLevel] of Object.entries(config.loglevel)) {
}
}
log.info(`==== Open Rowing Monitor ${process.env.npm_package_version} ====\n`)
log.info(`==== Open Rowing Monitor ${process.env.npm_package_version || ''} ====\n`)
const peripheralManager = createPeripheralManager()

17
babel.config.json Normal file
View File

@ -0,0 +1,17 @@
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"esmodules": true
},
"shippedProposals": true,
"bugfixes": true
}
]
],
"plugins": [
["@babel/plugin-proposal-decorators", { "decoratorsBeforeExport": true }]
]
}

View File

@ -98,14 +98,15 @@ CURRENT_DIR=$(pwd)
SCRIPT_DIR="$( cd "$(dirname "${BASH_SOURCE[0]}")" &> /dev/null && pwd )"
INSTALL_DIR="$(dirname "$SCRIPT_DIR")"
GIT_REMOTE="https://github.com/laberning/openrowingmonitor.git"
cd $INSTALL_DIR
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
LOCAL_VERSION=$(git rev-parse HEAD)
print "Update script for Open Rowing Monitor"
print
cd $INSTALL_DIR
if getopts "b:" arg; then
if [ $CURRENT_BRANCH = $OPTARG ]; then
cancel "No need to switch to branch \"$OPTARG\", it is already the active branch"

5
jsconfig.json Normal file
View File

@ -0,0 +1,5 @@
{
"compilerOptions": {
"experimentalDecorators": true
}
}

7847
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "openrowingmonitor",
"version": "0.8.0",
"version": "0.8.1",
"description": "A rowing monitor for rowing exercise machines",
"main": "app/server.js",
"author": "Lars Berning",
@ -16,9 +16,11 @@
"scripts": {
"lint": "eslint ./app ./config && markdownlint '**/*.md' --ignore node_modules",
"start": "node app/server.js",
"dev": "npm-run-all --parallel start build:watch",
"build": "snowpack build",
"build:watch": "snowpack build --watch",
"dev": "npm-run-all --parallel dev:backend dev:frontend",
"dev:backend": "nodemon app/server.js",
"dev:frontend": "snowpack dev",
"build": "rollup -c",
"build:watch": "rollup -cw",
"test": "uvu"
},
"simple-git-hooks": {
@ -29,6 +31,7 @@
"@abandonware/noble": "1.9.2-15",
"ant-plus": "0.1.24",
"finalhandler": "1.1.2",
"lit": "2.1.1",
"loglevel": "1.8.0",
"nosleep.js": "0.12.0",
"onoff": "6.0.3",
@ -40,15 +43,31 @@
"@abandonware/bluetooth-hci-socket": "0.5.3-7"
},
"devDependencies": {
"@babel/eslint-parser": "7.16.5",
"@babel/plugin-proposal-decorators": "7.16.7",
"@babel/preset-env": "7.16.7",
"@rollup/plugin-babel": "5.3.0",
"@rollup/plugin-commonjs": "21.0.1",
"@rollup/plugin-node-resolve": "13.1.3",
"@snowpack/plugin-babel": "2.1.7",
"@web/rollup-plugin-html": "1.10.1",
"eslint": "7.32.0",
"eslint-config-standard": "16.0.3",
"eslint-plugin-import": "2.25.4",
"eslint-plugin-lit": "1.6.1",
"eslint-plugin-node": "11.1.0",
"eslint-plugin-promise": "5.2.0",
"eslint-plugin-wc": "1.3.2",
"http2-proxy": "5.0.53",
"markdownlint-cli": "0.30.0",
"nodemon": "2.0.15",
"npm-run-all": "4.1.5",
"rollup": "2.63.0",
"rollup-plugin-summary": "1.3.0",
"rollup-plugin-terser": "7.0.2",
"simple-git-hooks": "2.7.0",
"snowpack": "3.8.8",
"tar": "6.1.11",
"uvu": "0.5.3"
}
}

51
rollup.config.js Normal file
View File

@ -0,0 +1,51 @@
// Import rollup plugins
import html from '@web/rollup-plugin-html'
import resolve from '@rollup/plugin-node-resolve'
import commonjs from '@rollup/plugin-commonjs'
import { babel } from '@rollup/plugin-babel'
import { terser } from 'rollup-plugin-terser'
import summary from 'rollup-plugin-summary'
// Configure an instance of @web/rollup-plugin-html
const htmlPlugin = html({
rootDir: './app/client',
flattenOutput: false
})
export default {
// Entry point for application build; can specify a glob to build multiple
// HTML files for non-SPA app
input: 'index.html',
plugins: [
htmlPlugin,
// transpile decorators so we can use the upcoming ES decorator syntax
babel({
babelrc: true,
babelHelpers: 'bundled'
}),
// convert modules with commonJS syntax to ESM
commonjs(),
// resolve bare module specifiers to relative paths
resolve(),
// minify JS
terser({
ecma: 2020,
module: true,
warnings: true,
mangle: {
properties: {
regex: /^__/
}
}
}),
summary()
],
output:
{
format: 'es',
chunkFileNames: '[name]-[hash].js',
entryFileNames: '[name]-[hash].js',
dir: 'build'
},
preserveEntrySignatures: false
}

View File

@ -1,19 +1,26 @@
// Snowpack Configuration File
// See all supported options: https://www.snowpack.dev/reference/configuration
import proxy from 'http2-proxy'
import { nodeResolve } from '@rollup/plugin-node-resolve'
// todo: might add a proxy for websockets here so we can use snowpack dev server with HMR
export default {
mount: {
// the web frontend is located in this directory
'./app/client': { url: '/' }
// mount "public" to the root URL path ("/*") and serve files with zero transformations:
// './public': { url: '/', static: true, resolve: false }
},
plugins: [
/* ... */
],
plugins: ['@snowpack/plugin-babel'],
mode: 'development',
packageOptions: {
/* ... */
rollup: {
plugins: [
// todo: related to the lit documentation this should enable development mode
// unfortunately this currently does not seem to work
nodeResolve({
exportConditions: ['development'],
dedupe: true
})
]
}
},
devOptions: {
open: 'none',
@ -21,5 +28,37 @@ export default {
},
buildOptions: {
out: 'build'
}
},
optimize: {
bundle: true,
treeshake: true,
minify: false,
target: 'es2020',
sourcemap: false
},
// add a proxy for websocket requests for the dev setting
routes: [
{
src: '/websocket',
upgrade: (req, socket, head) => {
const defaultWSHandler = (err, req, socket, head) => {
if (err) {
console.error('proxy error', err)
socket.destroy()
}
}
proxy.ws(
req,
socket,
head,
{
hostname: 'localhost',
port: 80
},
defaultWSHandler
)
}
}
]
}