From fe5a7e7ed13890ab3d48bb37b0ea692574220ce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=A1sz?= <> Date: Thu, 23 Mar 2023 18:14:44 +0100 Subject: [PATCH] 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 --- app/client/components/PerformanceDashboard.js | 70 ++++--------------- app/client/components/SettingsDialog.js | 31 +++----- app/client/lib/app.js | 21 +----- app/client/lib/helper.js | 56 +++++++++++++++ app/client/store/dashboardMetrics.js | 35 ++++++++++ 5 files changed, 114 insertions(+), 99 deletions(-) create mode 100644 app/client/store/dashboardMetrics.js diff --git a/app/client/components/PerformanceDashboard.js b/app/client/components/PerformanceDashboard.js index bca8821..48c1620 100644 --- a/app/client/components/PerformanceDashboard.js +++ b/app/client/components/PerformanceDashboard.js @@ -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``, + dashboardMetricComponentsFactory = (appState) => { + const metrics = appState.metrics + const configs = appState.config.guiConfigs - pace: html``, + const dashboardMetricComponents = Object.keys(DASHBOARD_METRICS).reduce((dashboardMetrics, key) => { + dashboardMetrics[key] = DASHBOARD_METRICS[key].template(metrics, configs.showIcons) - power: html``, + return dashboardMetrics + }, {}) - stkRate: html``, - - heartRate: html` - ${formattedMetrics?.heartrateBatteryLevel?.value - ? html`` - : ''} - `, - - totalStk: html``, - - calories: html``, - - timer: html``, - - forceCurve: html``, - - actions: html`` - }) + 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`
${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 - } } diff --git a/app/client/components/SettingsDialog.js b/app/client/components/SettingsDialog.js index d76c1cf..e6b1f7b 100644 --- a/app/client/components/SettingsDialog.js +++ b/app/client/components/SettingsDialog.js @@ -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 {

Select metrics to be shown:

- - - - - - - - - - - - - - - - - - - - + ${this.renderAvailableMetricList()}
Slots remaining: ${8 - this._sumSelectedSlots} @@ -161,6 +141,13 @@ export class DashboardActions extends AppElement { this._showIconInput.checked = this._showIcons } + renderAvailableMetricList () { + return Object.keys(DASHBOARD_METRICS).map(key => html` + + + `) + } + renderSelectedMetrics () { const selectedMetrics = [html`${[0, 1, 2, 3].map(index => html``)}`] selectedMetrics.push(html`${[4, 5, 6, 7].map(index => html``)}`) diff --git a/app/client/lib/app.js b/app/client/lib/app.js index 7ce4776..214c4d5 100644 --- a/app/client/lib/app.js +++ b/app/client/lib/app.js @@ -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) { diff --git a/app/client/lib/helper.js b/app/client/lib/helper.js index 16bae13..1bca643 100644 --- a/app/client/lib/helper.js +++ b/app/client/lib/helper.js @@ -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`` +} diff --git a/app/client/store/dashboardMetrics.js b/app/client/store/dashboardMetrics.js new file mode 100644 index 0000000..cd6ee12 --- /dev/null +++ b/app/client/store/dashboardMetrics.js @@ -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` + ${metrics?.heartrateBatteryLevel + ? html`` + : ''} +` + }, + 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`` }, + actions: { displayName: 'Actions', size: 1, template: () => html`` } +}
${this._selectedMetrics[index]}
${this._selectedMetrics[index]}