+ ${iconSettings} Settings
+
+
+
+ Grid layout (${this._isPortrait ? 'portrait' : 'landscape'})
+
+ Columns: ${this._columns}
+ { this._columns = parseInt((e.target as HTMLInputElement).value, 10) }}
+ />
+
+
+ Rows: ${this._rows}
+ { this._rows = parseInt((e.target as HTMLInputElement).value, 10) }}
+ />
+
+
+
Show icons
@@ -153,10 +166,6 @@ export class DashboardActions extends AppElement {
Experimental settings:
-
- Use 12 cell grid
-
-
True Black theme (OLED/AMOLED)
@@ -167,104 +176,39 @@ export class DashboardActions extends AppElement {
}
firstUpdated () {
- this._selectedMetrics = [...this.config.dashboardMetrics]
- this._sumSelectedSlots = this._selectedMetrics.length
+ this._isPortrait = window.matchMedia('(orientation: portrait)').matches
+ const orientConfig = this._isPortrait ? this.config.gridConfig.portrait : this.config.gridConfig.landscape
+ this._columns = orientConfig.columns
+ this._rows = orientConfig.rows
this._showIcons = this.config.showIcons
- this._maxNumberOfTiles = this.config.maxNumberOfTiles
this._trueBlackTheme = this.config.trueBlackTheme ?? false
- if (this._sumSelectedSlots === this._maxNumberOfTiles) {
- this._isValid = true
- } else {
- this._isValid = false
- }
- [...this._inputs].forEach((input) => {
- input.checked = this._selectedMetrics.find((metric) => metric === input.name) !== undefined
- })
this._showIconInput.checked = this._showIcons
- this._maxNumberOfTilesInput.checked = this._maxNumberOfTiles === 12
this._trueBlackThemeInput.checked = this._trueBlackTheme
}
- renderAvailableMetricList () {
- return Object.keys(DASHBOARD_METRICS).map((key) => html`
-
-
- ${DASHBOARD_METRICS[key].displayName}
- `)
- }
-
- renderSelectedMetrics () {
- const selectedMetrics = [html`${[0, 1, 2, 3].map((index: number) => html`${this._selectedMetrics[index]} `)} `]
- selectedMetrics.push(html`${[4, 5, 6, 7].map((index: number) => html`${this._selectedMetrics[index]} `)} `)
- if (this._maxNumberOfTiles === 12) {
- selectedMetrics.push(html`${[8, 9, 10, 11].map((index: number) => html`${this._selectedMetrics[index]} `)} `)
- }
-
- return selectedMetrics
- }
-
- toggleCheck (e: Event) {
- const target = e.target as HTMLInputElement & { size: number }
- if (target.checked && ((this._selectedMetrics.length % 4 === 3 && target.size > 1) || (this._sumSelectedSlots + target.size > this._maxNumberOfTiles))) {
- this._isValid = this.isFormValid()
- target.checked = false
- return
- }
-
- if (target.checked) {
- for (let index = 0; index < target.size; index++) {
- this._selectedMetrics = [...this._selectedMetrics, target.name]
- }
- } else {
- for (let index = 0; index < target.size; index++) {
- this._selectedMetrics.splice(this._selectedMetrics.findIndex((metric) => metric === target.name), 1)
- this._selectedMetrics = [...this._selectedMetrics]
- }
- }
-
- this._sumSelectedSlots = this._selectedMetrics.length
- if (this.isFormValid()) {
- this._isValid = true
- } else {
- this._isValid = false
- }
- }
-
toggleIcons (e: Event) {
this._showIcons = (e.target as HTMLInputElement).checked
}
- toggleMaxTiles (e: Event) {
- this._maxNumberOfTiles = (e.target as HTMLInputElement).checked ? 12 : 8
- this._isValid = this.isFormValid()
- }
-
toggleTrueBlackTheme (e: Event) {
this._trueBlackTheme = (e.target as HTMLInputElement).checked
}
- isFormValid () {
- return this._sumSelectedSlots === this._maxNumberOfTiles && this._selectedMetrics[3] !== this._selectedMetrics[4] && this._selectedMetrics[7] !== this._selectedMetrics?.[8]
- }
-
close (event: CustomEvent) {
this.dispatchEvent(new CustomEvent('close'))
if (event.detail === 'confirm') {
+ const gridConfig = this._isPortrait ?
+ {
+ landscape: { columns: this._rows, rows: this._columns },
+ portrait: { columns: this._columns, rows: this._rows }
+ } :
+ {
+ landscape: { columns: this._columns, rows: this._rows },
+ portrait: { columns: this._rows, rows: this._columns }
+ }
this.sendEvent('changeGuiSetting', {
- dashboardMetrics: this._selectedMetrics,
+ gridConfig,
showIcons: this._showIcons,
- maxNumberOfTiles: this._maxNumberOfTiles,
trueBlackTheme: this._trueBlackTheme
})
}
diff --git a/app/client/index.test.ts b/app/client/index.test.ts
index 66e27579d5..a23a0920c1 100644
--- a/app/client/index.test.ts
+++ b/app/client/index.test.ts
@@ -57,7 +57,7 @@ describe('dashboard metrics validation', () => {
const state = app.getState()
// unknownMetric and 'another' should be filtered out if they're not in DASHBOARD_METRICS
// 'validPace' is also not a real key, so all should be filtered
- const metrics = state.config.guiConfigs.dashboardMetrics
+ const metrics = state.config.guiConfigs.landscapeDashboardMetrics
metrics.forEach((metric: string) => {
// All remaining metrics should be valid (exist in DASHBOARD_METRICS)
// rather than the invalid ones we injected
diff --git a/app/client/index.ts b/app/client/index.ts
index 57abf23269..0a3b28c735 100644
--- a/app/client/index.ts
+++ b/app/client/index.ts
@@ -34,19 +34,31 @@ export class App extends LitElement {
// todo: we also want a mechanism here to get notified of state changes
})
+ // Migrate old 'dashboardMetrics' localStorage key to 'landscapeDashboardMetrics'
+ const legacyLandscape = localStorage.getItem('dashboardMetrics')
+ if (legacyLandscape && !localStorage.getItem('landscapeDashboardMetrics')) {
+ localStorage.setItem('landscapeDashboardMetrics', legacyLandscape)
+ localStorage.removeItem('dashboardMetrics')
+ }
+
const config = this._appState.config.guiConfigs
const configKey = Object.keys(config) as (keyof GuiConfig)[]
configKey.forEach((key) => {
let savedValue = JSON.parse(localStorage.getItem(key) ?? 'null') as GuiConfig[typeof key] | undefined
- // Validate dashboardMetrics against known valid keys
- if (key === 'dashboardMetrics' && Array.isArray(savedValue)) {
+ // Validate metrics arrays against known valid keys
+ if ((key === 'landscapeDashboardMetrics' || key === 'portraitDashboardMetrics') && Array.isArray(savedValue)) {
savedValue = savedValue.filter((metric: string) => DASHBOARD_METRICS[metric] !== undefined)
}
(config[key] as GuiConfig[typeof key]) = savedValue ?? config[key]
})
+ // If portrait metrics have never been saved, seed them from the landscape layout
+ if (!localStorage.getItem('portraitDashboardMetrics')) {
+ config.portraitDashboardMetrics = [...config.landscapeDashboardMetrics]
+ }
+
// apply theme based on saved preference
this.applyTheme(config.trueBlackTheme)
@@ -66,9 +78,12 @@ export class App extends LitElement {
this.addEventListener('changeGuiSetting', (event) => {
const detail = { ...(event as CustomEvent).detail }
- // Validate dashboardMetrics against known valid keys before saving
- if (Array.isArray(detail.dashboardMetrics)) {
- detail.dashboardMetrics = detail.dashboardMetrics.filter((metric: string) => DASHBOARD_METRICS[metric] !== undefined)
+ // Validate metrics arrays against known valid keys before saving
+ if (Array.isArray(detail.landscapeDashboardMetrics)) {
+ detail.landscapeDashboardMetrics = detail.landscapeDashboardMetrics.filter((metric: string) => DASHBOARD_METRICS[metric] !== undefined)
+ }
+ if (Array.isArray(detail.portraitDashboardMetrics)) {
+ detail.portraitDashboardMetrics = detail.portraitDashboardMetrics.filter((metric: string) => DASHBOARD_METRICS[metric] !== undefined)
}
Object.keys(detail).forEach((key) => {
diff --git a/app/client/store/appState.ts b/app/client/store/appState.ts
index 9e10a43652..6b3eb47c15 100644
--- a/app/client/store/appState.ts
+++ b/app/client/store/appState.ts
@@ -49,11 +49,15 @@ export const APP_STATE: AppState = {
// true if remote device shutdown is enabled
shutdownEnabled: false,
guiConfigs: {
- dashboardMetrics: ['distance', 'timer', 'pace', 'power', 'stkRate', 'totalStk', 'calories'],
+ landscapeDashboardMetrics: ['distance', 'timer', 'pace', 'power', 'stkRate', 'totalStk', 'calories'],
+ portraitDashboardMetrics: ['distance', 'timer', 'pace', 'power', 'stkRate', 'totalStk', 'calories'],
showIcons: true,
- maxNumberOfTiles: 8,
trueBlackTheme: false,
- forceCurveDivisionMode: 0
+ forceCurveDivisionMode: 0,
+ gridConfig: {
+ landscape: { columns: 4, rows: 2 },
+ portrait: { columns: 2, rows: 4 }
+ }
}
}
}
diff --git a/app/client/store/dashboardMetrics.ts b/app/client/store/dashboardMetrics.ts
index f2a4204986..edd1003975 100644
--- a/app/client/store/dashboardMetrics.ts
+++ b/app/client/store/dashboardMetrics.ts
@@ -14,7 +14,7 @@ export const DASHBOARD_METRICS: Record = {
distance: {
displayName: 'Distance',
size: 1,
- template: (metrics, config, onWorkoutOpen) => {
+ template: (metrics, config, onWorkoutOpen, slotContent = '') => {
let distance
switch (true) {
case (metrics?.interval?.type === 'rest' && metrics?.pauseCountdownTime > 0):
@@ -29,52 +29,57 @@ export const DASHBOARD_METRICS: Record = {
const linearDistance = formatDistance(distance ?? 0)
return html` onWorkoutOpen?.('distance')}
+ style="${onWorkoutOpen ? 'cursor:pointer' : ''}"
+ @click=${onWorkoutOpen ? () => onWorkoutOpen('distance') : undefined}
.icon=${config?.guiConfigs?.showIcons ? iconRoute : ''}
.unit=${linearDistance.unit}
.value=${linearDistance.distance}
- > `
+ >
+ ${slotContent}
+ `
}
},
- pace: { displayName: 'Pace/500', size: 1, template: (metrics, config) => simpleMetricFactory(secondsToTimeString(metrics?.cyclePace), '/500m', config?.guiConfigs?.showIcons ? iconStopwatch : '') },
+ pace: { displayName: 'Pace/500', size: 1, template: (metrics, config, _onWorkoutOpen, slotContent = '') => simpleMetricFactory(secondsToTimeString(metrics?.cyclePace), '/500m', config?.guiConfigs?.showIcons ? iconStopwatch : '', slotContent) },
- power: { displayName: 'Power', size: 1, template: (metrics, config) => simpleMetricFactory(formatNumber(metrics?.cyclePower), 'watt', config?.guiConfigs?.showIcons ? iconBolt : '') },
+ power: { displayName: 'Power', size: 1, template: (metrics, config, _onWorkoutOpen, slotContent = '') => simpleMetricFactory(formatNumber(metrics?.cyclePower), 'watt', config?.guiConfigs?.showIcons ? iconBolt : '', slotContent) },
- stkRate: { displayName: 'Stroke rate', size: 1, template: (metrics, config) => simpleMetricFactory(formatNumber(metrics?.cycleStrokeRate), '/min', config?.guiConfigs?.showIcons ? iconPaddle : '') },
+ stkRate: { displayName: 'Stroke rate', size: 1, template: (metrics, config, _onWorkoutOpen, slotContent = '') => simpleMetricFactory(formatNumber(metrics?.cycleStrokeRate), '/min', config?.guiConfigs?.showIcons ? iconPaddle : '', slotContent) },
heartRate: {
displayName: 'Heart rate',
size: 1,
- template: (metrics, config) => html`
+ template: (metrics, config, _onWorkoutOpen, slotContent = '') => html`
${(metrics?.heartRateBatteryLevel ?? 0) > 0 ?
html` ` :
''}
+ ${slotContent}
`
},
- totalStk: { displayName: 'Total strokes', size: 1, template: (metrics, config) => simpleMetricFactory(metrics?.interval?.numberOfStrokes, 'stk', config?.guiConfigs?.showIcons ? iconPaddle : '') },
+ totalStk: { displayName: 'Total strokes', size: 1, template: (metrics, config, _onWorkoutOpen, slotContent = '') => simpleMetricFactory(metrics?.interval?.numberOfStrokes, 'stk', config?.guiConfigs?.showIcons ? iconPaddle : '', slotContent) },
calories: {
displayName: 'Calories',
size: 1,
- template: (metrics, config, onWorkoutOpen) => {
+ template: (metrics, config, onWorkoutOpen, slotContent = '') => {
const calories = metrics?.interval?.type === 'calories' ? Math.max(metrics?.interval?.calories?.toEnd ?? 0, 0) : Math.max(metrics?.interval?.calories?.sinceStart ?? 0, 0)
return html` onWorkoutOpen?.('calories')}
+ style="${onWorkoutOpen ? 'cursor:pointer' : ''}"
+ @click=${onWorkoutOpen ? () => onWorkoutOpen('calories') : undefined}
.icon=${config?.guiConfigs?.showIcons ? iconFire : ''}
.unit=${'kcal'}
.value=${formatNumber(calories ?? 0)}
- > `
+ >
+ ${slotContent}
+ `
}
},
timer: {
displayName: 'Timer',
size: 1,
- template: (metrics, config, onWorkoutOpen) => {
+ template: (metrics, config, onWorkoutOpen, slotContent = '') => {
let time
let icon
switch (true) {
@@ -92,40 +97,43 @@ export const DASHBOARD_METRICS: Record = {
}
return html` onWorkoutOpen?.('time')}
+ style="${onWorkoutOpen ? 'cursor:pointer' : ''}"
+ @click=${onWorkoutOpen ? () => onWorkoutOpen('time') : undefined}
.icon=${config?.guiConfigs?.showIcons ? icon : ''}
.unit=${''}
.value=${secondsToTimeString(time ?? 0)}
- > `
+ >
+ ${slotContent}
+ `
}
},
- distancePerStk: { displayName: 'Dist per Stroke', size: 1, template: (metrics, config) => simpleMetricFactory(formatNumber(metrics?.cycleDistance, 1), 'm', config?.guiConfigs?.showIcons ? rowerIcon : '') },
+ distancePerStk: { displayName: 'Dist per Stroke', size: 1, template: (metrics, config, _onWorkoutOpen, slotContent = '') => simpleMetricFactory(formatNumber(metrics?.cycleDistance, 1), 'm', config?.guiConfigs?.showIcons ? rowerIcon : '', slotContent) },
- dragFactor: { displayName: 'Drag factor', size: 1, template: (metrics, config) => simpleMetricFactory(formatNumber(metrics?.dragFactor), '', config?.guiConfigs?.showIcons ? 'Drag' : '') },
+ dragFactor: { displayName: 'Drag factor', size: 1, template: (metrics, config, _onWorkoutOpen, slotContent = '') => simpleMetricFactory(formatNumber(metrics?.dragFactor), '', config?.guiConfigs?.showIcons ? 'Drag' : '', slotContent) },
- driveLength: { displayName: 'Drive length', size: 1, template: (metrics, config) => simpleMetricFactory(formatNumber(metrics?.driveLength, 2), 'm', config?.guiConfigs?.showIcons ? 'Drive' : '') },
+ driveLength: { displayName: 'Drive length', size: 1, template: (metrics, config, _onWorkoutOpen, slotContent = '') => simpleMetricFactory(formatNumber(metrics?.driveLength, 2), 'm', config?.guiConfigs?.showIcons ? 'Drive' : '', slotContent) },
- driveDuration: { displayName: 'Drive duration', size: 1, template: (metrics, config) => simpleMetricFactory(formatNumber(metrics?.driveDuration, 2), 'sec', config?.guiConfigs?.showIcons ? 'Drive' : '') },
+ driveDuration: { displayName: 'Drive duration', size: 1, template: (metrics, config, _onWorkoutOpen, slotContent = '') => simpleMetricFactory(formatNumber(metrics?.driveDuration, 2), 'sec', config?.guiConfigs?.showIcons ? 'Drive' : '', slotContent) },
- recoveryDuration: { displayName: 'Recovery duration', size: 1, template: (metrics, config) => simpleMetricFactory(formatNumber(metrics?.recoveryDuration, 2), 'sec', config?.guiConfigs?.showIcons ? 'Recovery' : '') },
+ recoveryDuration: { displayName: 'Recovery duration', size: 1, template: (metrics, config, _onWorkoutOpen, slotContent = '') => simpleMetricFactory(formatNumber(metrics?.recoveryDuration, 2), 'sec', config?.guiConfigs?.showIcons ? 'Recovery' : '', slotContent) },
- forceCurve: { displayName: 'Force curve', size: 2, template: (metrics, config) => html`
+ forceCurve: { displayName: 'Force curve', size: 2, template: (metrics, config, _onWorkoutOpen, slotContent = '') => html`
+ >
+ ${slotContent}
+
` },
- peakForce: { displayName: 'Peak Force', size: 1, template: (metrics) => simpleMetricFactory(formatNumber(metrics?.drivePeakHandleForce), 'N', 'Peak Force') },
+ peakForce: { displayName: 'Peak Force', size: 1, template: (metrics, _config, _onWorkoutOpen, slotContent = '') => simpleMetricFactory(formatNumber(metrics?.drivePeakHandleForce), 'N', 'Peak Force', slotContent) },
strokeRatio: {
displayName: 'Stroke Ratio',
size: 1,
- template: (metrics) => {
+ template: (metrics, _config, _onWorkoutOpen, slotContent = '') => {
// Check to make sure both values are truthy
// no 0, null, or undefined
const driveDuration = metrics?.driveDuration
@@ -138,17 +146,18 @@ export const DASHBOARD_METRICS: Record = {
ratio = undefined
}
- return simpleMetricFactory(ratio, '', 'Ratio')
+ return simpleMetricFactory(ratio, '', 'Ratio', slotContent)
}
}
}
/**
* Helper function to create a simple metric tile
- * @param {string | number} value The metric to show
- * @param {string} unit The unit of the metric.
- * @param {string | import('lit').TemplateResult<2>} icon The number of decimal places to round to (default: 0).
+ * @param value The metric to show
+ * @param unit The unit of the metric.
+ * @param icon The icon or label to display.
+ * @param slotContent Optional content to render inside the metric slot (e.g. retile controls).
*/
-function simpleMetricFactory (value: string | number | undefined = '--', unit = '', icon: string | TemplateResult = '') {
- return html` `
+function simpleMetricFactory (value: string | number | undefined = '--', unit = '', icon: string | TemplateResult = '', slotContent: TemplateResult | string = '') {
+ return html`${slotContent} `
}
diff --git a/app/client/store/types.ts b/app/client/store/types.ts
index 09a79fcdc2..0cdade69cb 100644
--- a/app/client/store/types.ts
+++ b/app/client/store/types.ts
@@ -105,12 +105,23 @@ export interface RowingMetrics {
workout?: IntervalData
}
+export interface GridOrientationConfig {
+ columns: number
+ rows: number
+}
+
+export interface GridConfig {
+ landscape: GridOrientationConfig
+ portrait: GridOrientationConfig
+}
+
export interface GuiConfig {
- dashboardMetrics: string[]
+ landscapeDashboardMetrics: string[]
+ portraitDashboardMetrics: string[]
showIcons: boolean
- maxNumberOfTiles: number
trueBlackTheme: boolean
forceCurveDivisionMode: number
+ gridConfig: GridConfig
}
export interface AppConfig {
@@ -130,5 +141,5 @@ export interface AppState {
export interface DashboardMetricDefinition {
displayName: string
size: number
- template: (metrics: RowingMetrics, config?: AppConfig, onWorkoutOpen?: (type: string) => void) => TemplateResult
+ template: (metrics: RowingMetrics, config?: AppConfig, onWorkoutOpen?: (type: string) => void, slotContent?: TemplateResult | string) => TemplateResult
}