Remove dependency on server formatted values

Remove dependency on metric formatting logic at the server. Implement
the formatting of the raw data on the client side. Make adding
new metric tiles more modular and extensible via simplified creation API
This commit is contained in:
Abász 2023-03-23 18:14:44 +01:00
parent 6834e3a558
commit fe5a7e7ed1
5 changed files with 114 additions and 99 deletions

View File

@ -7,12 +7,9 @@
import { AppElement, html, css } from './AppElement.js'
import { customElement, state } from 'lit/decorators.js'
import './DashboardForceCurve.js'
import './DashboardMetric.js'
import './DashboardActions.js'
import './BatteryIcon.js'
import './SettingsDialog'
import { icon_route, icon_stopwatch, icon_bolt, icon_paddle, icon_heartbeat, icon_fire, icon_clock, icon_settings } from '../lib/icons.js'
import { icon_settings } from '../lib/icons.js'
import { DASHBOARD_METRICS } from '../store/dashboardMetrics.js'
@customElement('performance-dashboard')
export class PerformanceDashboard extends AppElement {
@ -65,39 +62,25 @@ export class PerformanceDashboard extends AppElement {
@state()
_dialog
dashboardMetricComponents = (formattedMetrics, appState) => ({
distance: html`<dashboard-metric .icon=${this.appState.config.guiConfigs.showIcons ? icon_route : ''} .unit=${formattedMetrics?.totalLinearDistanceFormatted?.unit || 'm'} .value=${formattedMetrics?.totalLinearDistanceFormatted?.value}></dashboard-metric>`,
dashboardMetricComponentsFactory = (appState) => {
const metrics = appState.metrics
const configs = appState.config.guiConfigs
pace: html`<dashboard-metric .icon=${this.appState.config.guiConfigs.showIcons ? icon_stopwatch : ''} unit="/500m" .value=${formattedMetrics?.cyclePaceFormatted?.value}></dashboard-metric>`,
const dashboardMetricComponents = Object.keys(DASHBOARD_METRICS).reduce((dashboardMetrics, key) => {
dashboardMetrics[key] = DASHBOARD_METRICS[key].template(metrics, configs.showIcons)
power: html`<dashboard-metric .icon=${this.appState.config.guiConfigs.showIcons ? icon_bolt : ''} unit="watt" .value=${formattedMetrics?.cyclePower?.value}></dashboard-metric>`,
return dashboardMetrics
}, {})
stkRate: html`<dashboard-metric .icon=${this.appState.config.guiConfigs.showIcons ? icon_paddle : ''} unit="/min" .value=${formattedMetrics?.cycleStrokeRate?.value}></dashboard-metric>`,
heartRate: html`<dashboard-metric .icon=${this.appState.config.guiConfigs.showIcons ? icon_heartbeat : ''} unit="bpm" .value=${formattedMetrics?.heartrate?.value}>
${formattedMetrics?.heartrateBatteryLevel?.value
? html`<battery-icon .batteryLevel=${formattedMetrics?.heartrateBatteryLevel?.value}></battery-icon>`
: ''}
</dashboard-metric>`,
totalStk: html`<dashboard-metric .icon=${this.appState.config.guiConfigs.showIcons ? icon_paddle : ''} unit="total" .value=${formattedMetrics?.totalNumberOfStrokes?.value}></dashboard-metric>`,
calories: html`<dashboard-metric .icon=${this.appState.config.guiConfigs.showIcons ? icon_fire : ''} unit="kcal" .value=${formattedMetrics?.totalCalories?.value}></dashboard-metric>`,
timer: html`<dashboard-metric .icon=${this.appState.config.guiConfigs.showIcons ? icon_clock : ''} .value=${formattedMetrics?.totalMovingTimeFormatted?.value}></dashboard-metric>`,
forceCurve: html`<dashboard-force-curve .value=${appState?.metrics.driveHandleForceCurve} style="grid-column: span 2"></dashboard-force-curve>`,
actions: html`<dashboard-actions .appState=${appState}></dashboard-actions>`
})
return dashboardMetricComponents
}
render () {
const metricConfig = [...new Set(this.appState.config.guiConfigs.dashboardMetrics)].reduce((prev, metricName) => {
prev.push(this.dashboardMetricComponents(this.metrics, this.appState)[metricName])
prev.push(this.dashboardMetricComponentsFactory(this.appState)[metricName])
return prev
}, [])
this.metrics = this.calculateFormattedMetrics(this.appState.metrics)
return html`
<div class="settings" @click=${this.openSettings}>
${icon_settings}
@ -115,33 +98,4 @@ export class PerformanceDashboard extends AppElement {
this._dialog = undefined
}
}
// todo: so far this is just a port of the formatter from the initial proof of concept client
// we could split this up to make it more readable and testable
calculateFormattedMetrics (metrics) {
const fieldFormatter = {
totalLinearDistanceFormatted: (value) => value >= 10000
? { value: (value / 1000).toFixed(2), unit: 'km' }
: { value: Math.round(value), unit: 'm' },
totalCalories: (value) => Math.round(value),
cyclePower: (value) => Math.round(value),
cycleStrokeRate: (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

@ -9,6 +9,7 @@ import { AppElement, html, css } from './AppElement.js'
import { customElement, property, query, queryAll, state } from 'lit/decorators.js'
import { icon_settings } from '../lib/icons.js'
import './AppDialog.js'
import { DASHBOARD_METRICS } from '../store/dashboardMetrics.js'
@customElement('settings-dialog')
export class DashboardActions extends AppElement {
@ -28,9 +29,7 @@ export class DashboardActions extends AppElement {
.settings-dialog>div.metric-selector{
display: grid;
grid-template-columns: repeat(4,max-content);
grid-template-rows: repeat(3, max-content);
gap: 8px;
}
.settings-dialog>div>label{
@ -112,26 +111,7 @@ export class DashboardActions extends AppElement {
<p>Select metrics to be shown:</p>
<div class="metric-selector">
<label for="distance">Distance</label>
<input @change=${this.toggleCheck} name="distance" size=1 type="checkbox" />
<label for="timer">Timer</label>
<input @change=${this.toggleCheck} name="timer" size=1 type="checkbox" />
<label for="pace">Pace</label>
<input @change=${this.toggleCheck} name="pace" size=1 type="checkbox" />
<label for="power">Power</label>
<input @change=${this.toggleCheck} name="power" size=1 type="checkbox" />
<label for="stk">Stroke Rate</label>
<input @change=${this.toggleCheck} name="stkRate" size=1 type="checkbox" />
<label for="totalStrokes">Total Strokes</label>
<input @change=${this.toggleCheck} name="totalStk" size=1 type="checkbox" />
<label for="calories">Calories</label>
<input @change=${this.toggleCheck} name="calories" size=1 type="checkbox" />
<label for="actions">Heart Rate</label>
<input @change=${this.toggleCheck} name="heartRate" size=1 type="checkbox" />
<label for="forceCurve">Force Curve</label>
<input @change=${this.toggleCheck} name="forceCurve" size=2 type="checkbox" />
<label for="actions">Actions</label>
<input @change=${this.toggleCheck} name="actions" size=1 type="checkbox" />
${this.renderAvailableMetricList()}
</div>
<div class="metric-selector-feedback">Slots remaining: ${8 - this._sumSelectedSlots}
<table>
@ -161,6 +141,13 @@ export class DashboardActions extends AppElement {
this._showIconInput.checked = this._showIcons
}
renderAvailableMetricList () {
return Object.keys(DASHBOARD_METRICS).map(key => html`
<label for=${key}>${DASHBOARD_METRICS[key].displayName}</label>
<input @change=${this.toggleCheck} name=${key} size=${DASHBOARD_METRICS[key].size} type="checkbox" />
`)
}
renderSelectedMetrics () {
const selectedMetrics = [html`<tr>${[0, 1, 2, 3].map(index => html`<td style="${this._selectedMetrics[3] === this._selectedMetrics[4] && index === 3 ? 'color: red' : ''}">${this._selectedMetrics[index]}</td>`)}</tr>`]
selectedMetrics.push(html`<tr>${[4, 5, 6, 7].map(index => html`<td style="${this._selectedMetrics[3] === this._selectedMetrics[4] && index === 4 ? 'color: red' : ''}">${this._selectedMetrics[index]}</td>`)}</tr>`)

View File

@ -8,9 +8,6 @@
import NoSleep from 'nosleep.js'
import { filterObjectByKeys } from './helper.js'
const rowingMetricsFields = ['totalNumberOfStrokes', 'totalLinearDistanceFormatted', 'totalCalories', 'cyclePower', 'heartrate',
'heartrateBatteryLevel', 'cyclePaceFormatted', 'cycleStrokeRate', 'totalMovingTimeFormatted', 'driveHandleForceCurve']
export function createApp (app) {
const urlParameters = new URLSearchParams(window.location.search)
const mode = urlParameters.get('mode')
@ -75,20 +72,7 @@ export function createApp (app) {
break
}
case 'metrics': {
let activeFields = rowingMetricsFields
// if we are in reset state only update heart rate and peripheral mode
if (data.totalNumberOfStrokes < 1) {
if (data.totalLinearDistanceFormatted > 0) {
activeFields = ['totalLinearDistanceFormatted', 'heartrate', 'heartrateBatteryLevel', 'driveHandleForceCurve']
} else if (data.totalMovingTimeFormatted !== '00:00') {
activeFields = ['totalMovingTimeFormatted', 'heartrate', 'heartrateBatteryLevel', 'driveHandleForceCurve']
} else {
activeFields = ['heartrate', 'heartrateBatteryLevel', 'driveHandleForceCurve']
}
}
const filteredData = filterObjectByKeys(data, activeFields)
app.updateState({ ...app.getState(), metrics: filteredData })
app.updateState({ ...app.getState(), metrics: data })
break
}
case 'authorizeStrava': {
@ -124,8 +108,7 @@ export function createApp (app) {
function resetFields () {
const appState = app.getState()
// drop all metrics except heartrate
appState.metrics = filterObjectByKeys(appState.metrics, ['heartrate', 'heartrateBatteryLevel'])
app.updateState(appState)
app.updateState({ ...appState, metrics: { ...filterObjectByKeys(appState.metrics, ['heartrate', 'heartrateBatteryLevel']) } })
}
function handleAction (action) {

View File

@ -1,4 +1,8 @@
'use strict'
import { html } from 'lit'
import '../components/DashboardMetric.js'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
@ -14,3 +18,55 @@ export function filterObjectByKeys (object, keys) {
return obj
}, {})
}
/**
* Pipe for converting seconds to pace format 00:00
*
* @param seconds The actual time in seconds.
*/
export function secondsToPace (seconds) {
const hours = Math.floor((seconds % 86400) / 3600)
const mins = Math.floor(((seconds % 86400) % 3600) / 60)
if (seconds === undefined || seconds === null || seconds === Infinity || isNaN(seconds)) return '--'
if (hours > 0) {
return `${hours}:${mins.toString().padStart(2, '0')}:${(Math.round(seconds) % 60)
.toString()
.padStart(2, '0')}`
} else {
return `${mins}:${(Math.round(seconds) % 60).toString().padStart(2, '0')}`
}
}
/**
* Pipe for formatting distance in meters with units
*
* @param value The distance in meters.
* @param showInMiles Boolean whether to use imperial metric (default: false).
*/
export function formatDistance (value, showInMiles = false) {
if (showInMiles === false) {
return value >= 10000
? { distance: formatNumber((value / 1000), 2), unit: 'km' }
: { distance: formatNumber(value), unit: 'm' }
}
return { distance: formatNumber((value / 1609.344), 2), unit: 'mi' }
}
/**
* Pipe for formatting numbers to specific decimal
*
* @param value The number.
* @param decimalPlaces The number of decimal places to round to (default: 0).
*/
export function formatNumber (value, decimalPlaces = 0) {
const decimal = decimalPlaces > 0 ? decimalPlaces * 10 : 1
if (value === undefined || value === null || value === Infinity || isNaN(value) || value === 0) { return '--' }
return Math.round(value * decimal) / decimal
}
export function simpleMetricFactory (value, unit, icon) {
return html`<dashboard-metric .icon=${icon} .unit=${unit} .value=${value}></dashboard-metric>`
}

View File

@ -0,0 +1,35 @@
import { html } from 'lit'
import { simpleMetricFactory, formatDistance, formatNumber, secondsToPace } from '../lib/helper'
import { icon_bolt, icon_clock, icon_fire, icon_heartbeat, icon_paddle, icon_route, icon_stopwatch } from '../lib/icons'
import '../components/DashboardForceCurve.js'
import '../components/DashboardActions.js'
import '../components/BatteryIcon.js'
export const DASHBOARD_METRICS = {
distance: {
displayName: 'Distance',
size: 1,
template: (metrics, showIcon) => {
const linearDistance = formatDistance(metrics?.totalLinearDistance)
return simpleMetricFactory(linearDistance.distance, linearDistance.unit, showIcon ? icon_route : '')
}
},
pace: { displayName: 'Pace/500', size: 1, template: (metrics, showIcon) => simpleMetricFactory(secondsToPace(500 / metrics?.cycleLinearVelocity), '/500m', showIcon ? icon_stopwatch : '') },
power: { displayName: 'Power', size: 1, template: (metrics, showIcon) => simpleMetricFactory(formatNumber(metrics?.cyclePower), 'watt', showIcon ? icon_bolt : '') },
stkRate: { displayName: 'Stroke rate', size: 1, template: (metrics, showIcon) => simpleMetricFactory(formatNumber(metrics?.cycleStrokeRate), '/min', showIcon ? icon_paddle : '') },
heartRate: {
displayName: 'Heart rate',
size: 1,
template: (metrics, showIcon) => html`<dashboard-metric .icon=${showIcon ? icon_heartbeat : ''} unit="bpm" .value=${formatNumber(metrics?.heartrate)}>
${metrics?.heartrateBatteryLevel
? html`<battery-icon .batteryLevel=${metrics?.heartrateBatteryLevel}></battery-icon>`
: ''}
</dashboard-metric>`
},
totalStk: { displayName: 'Total strokes', size: 1, template: (metrics, showIcon) => simpleMetricFactory(metrics?.totalNumberOfStrokes, 'stk', showIcon ? icon_paddle : '') },
calories: { displayName: 'Calories', size: 1, template: (metrics, showIcon) => simpleMetricFactory(formatNumber(metrics?.totalCalories), 'kcal', showIcon ? icon_fire : '') },
timer: { displayName: 'Timer', size: 1, template: (metrics, showIcon) => simpleMetricFactory(secondsToPace(metrics?.totalMovingTime), '', showIcon ? icon_clock : '') },
forceCurve: { displayName: 'Force curve', size: 2, template: (metrics) => html`<dashboard-force-curve .value=${metrics.driveHandleForceCurve} style="grid-column: span 2"></dashboard-force-curve>` },
actions: { displayName: 'Actions', size: 1, template: () => html`<dashboard-actions></dashboard-actions>` }
}