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:
parent
6834e3a558
commit
fe5a7e7ed1
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>`)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>` }
|
||||
}
|
||||
Loading…
Reference in New Issue