From 52973225be80fdbe0cb3f893c26f1b21cd579ff7 Mon Sep 17 00:00:00 2001 From: DXCanas <9877852+DXCanas@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:29:56 +0000 Subject: [PATCH 01/10] Add grid configuration to app state - Add GridOrientationConfig and GridConfig types to store/types.ts - Add gridConfig defaults (landscape: 4x2, portrait: 2x4) to appState.ts - Update test fixtures to include the new gridConfig property Co-authored-by: Abasz <32517724+Abasz@users.noreply.github.com> --- app/client/components/DashboardToolbar.test.ts | 6 +++++- app/client/components/SettingsDialog.test.ts | 6 +++++- app/client/store/appState.ts | 6 +++++- app/client/store/types.ts | 13 ++++++++++++- 4 files changed, 27 insertions(+), 4 deletions(-) diff --git a/app/client/components/DashboardToolbar.test.ts b/app/client/components/DashboardToolbar.test.ts index a266ba5f85..b86b9aa599 100644 --- a/app/client/components/DashboardToolbar.test.ts +++ b/app/client/components/DashboardToolbar.test.ts @@ -20,7 +20,11 @@ function createToolbar (config: Partial = {}): DashboardToolbar { showIcons: true, maxNumberOfTiles: 8, trueBlackTheme: false, - forceCurveDivisionMode: 0 + forceCurveDivisionMode: 0, + gridConfig: { + landscape: { columns: 4, rows: 2 }, + portrait: { columns: 2, rows: 4 } + } }, ...config } diff --git a/app/client/components/SettingsDialog.test.ts b/app/client/components/SettingsDialog.test.ts index c9ed7db505..46e9971827 100644 --- a/app/client/components/SettingsDialog.test.ts +++ b/app/client/components/SettingsDialog.test.ts @@ -14,7 +14,11 @@ function createSettingsDialog (): SettingsDialog { showIcons: true, maxNumberOfTiles: 8, trueBlackTheme: false, - forceCurveDivisionMode: 0 + forceCurveDivisionMode: 0, + gridConfig: { + landscape: { columns: 4, rows: 2 }, + portrait: { columns: 2, rows: 4 } + } } return dialog } diff --git a/app/client/store/appState.ts b/app/client/store/appState.ts index 9e10a43652..be0aa37de2 100644 --- a/app/client/store/appState.ts +++ b/app/client/store/appState.ts @@ -53,7 +53,11 @@ export const APP_STATE: AppState = { 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/types.ts b/app/client/store/types.ts index 09a79fcdc2..bdd540529e 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[] 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 } From e74810b08564b3ff24504028c930120a47e3c5f0 Mon Sep 17 00:00:00 2001 From: DXCanas <9877852+DXCanas@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:31:20 +0000 Subject: [PATCH 02/10] Add slot support to dashboard metrics Add slotContent parameter to all metric template functions in dashboardMetrics.ts and the simpleMetricFactory helper. Restructure DashboardMetric render to use CSS classes (with-icon) instead of inline styles, and move slot inside the icon/label div. Add :host positioning to BatteryIcon. Add :host grid-column span and slot support to DashboardForceCurve. Update DashboardMetric tests for new class-based rendering. Co-authored-by: Abasz <32517724+Abasz@users.noreply.github.com> --- app/client/components/BatteryIcon.ts | 6 ++ app/client/components/DashboardForceCurve.ts | 8 ++- app/client/components/DashboardMetric.test.ts | 14 ++--- app/client/components/DashboardMetric.ts | 21 ++++--- app/client/store/dashboardMetrics.ts | 63 +++++++++++-------- 5 files changed, 67 insertions(+), 45 deletions(-) diff --git a/app/client/components/BatteryIcon.ts b/app/client/components/BatteryIcon.ts index b129816551..1953df3055 100644 --- a/app/client/components/BatteryIcon.ts +++ b/app/client/components/BatteryIcon.ts @@ -10,6 +10,12 @@ import { customElement, property } from 'lit/decorators.js' @customElement('battery-icon') export class DashboardMetric extends AppElement { static styles = css` + :host { + right: 0.2em; + bottom: 0; + position: absolute; + } + .icon { height: 1.2em; } diff --git a/app/client/components/DashboardForceCurve.ts b/app/client/components/DashboardForceCurve.ts index 6833d6b5f4..9337e73176 100644 --- a/app/client/components/DashboardForceCurve.ts +++ b/app/client/components/DashboardForceCurve.ts @@ -39,6 +39,7 @@ export class DashboardForceCurve extends AppElement { :host { display: block; position: relative; + grid-column: span 2; } .title { @@ -181,7 +182,12 @@ export class DashboardForceCurve extends AppElement { return html` ${this._chart?.data.datasets[0].data.length ? '' : - html`
Force Curve
` + html` +
+ Force Curve + +
+ ` } ` diff --git a/app/client/components/DashboardMetric.test.ts b/app/client/components/DashboardMetric.test.ts index a98e9a8ba1..f8d9ce9c51 100644 --- a/app/client/components/DashboardMetric.test.ts +++ b/app/client/components/DashboardMetric.test.ts @@ -24,21 +24,21 @@ describe('value display', () => { }) describe('icon rendering', () => { - test('should use empty class strings when no icon is provided', () => { + test('should use label class when no icon is provided', () => { const el = new DashboardMetric() el.icon = '' const result = (el as any).render() - // When icon is '', the class is '' and font-size style is 200% - expect(result.values).toContain('font-size: 200%;') + // When icon is '', hasIcon is false, so the div class is 'label' + expect(result.values).toContain('label') }) - test('should use label/icon classes when an icon is provided', () => { + test('should use icon class when an icon is provided', () => { const el = new DashboardMetric() el.icon = 'some-icon' const result = (el as any).render() - expect(result.values).toContain('label') + // When icon is not '', hasIcon is true, so the div class is 'icon' expect(result.values).toContain('icon') - // When icon is not '', there's no inline font-size override - expect(result.values).not.toContain('font-size: 200%;') + // metric-value gets 'with-icon' added via template interpolation + expect(result.values).toContain('with-icon') }) }) diff --git a/app/client/components/DashboardMetric.ts b/app/client/components/DashboardMetric.ts index ed3157e9c9..0131ac9958 100644 --- a/app/client/components/DashboardMetric.ts +++ b/app/client/components/DashboardMetric.ts @@ -22,14 +22,12 @@ export class DashboardMetric extends AppElement { font-size: 150%; } - .metric-unit { - font-size: 80%; + .metric-value.with-icon { + font-size: 200%; } - ::slotted(*) { - right: 0.2em; - bottom: 0; - position: absolute; + .metric-unit { + font-size: 80%; } ` @@ -43,15 +41,18 @@ export class DashboardMetric extends AppElement { value: string | number | undefined render () { + const hasIcon = this.icon !== '' return html` -
-
${this.icon}
+
+ ${this.icon} +
- ${this.value !== undefined ? this.value : '--'} + + ${this.value !== undefined ? this.value : '--'} + ${this.unit}
- ` } } diff --git a/app/client/store/dashboardMetrics.ts b/app/client/store/dashboardMetrics.ts index f2a4204986..ef741f76ef 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): @@ -34,31 +34,34 @@ export const DASHBOARD_METRICS: Record = { .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` = { .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) { @@ -97,35 +102,38 @@ export const DASHBOARD_METRICS: Record = { .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}` } From 762324d52ed15eda22b0b864242d36269f147b3a Mon Sep 17 00:00:00 2001 From: DXCanas <9877852+DXCanas@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:32:37 +0000 Subject: [PATCH 03/10] Add retile mode to toolbar - Add retile/submit toggle button with active state styling. - Add reset-to-default button (visible only in retile mode). - Move settings dialog ownership to parent via open-settings event. - Add toggleRetileMode and resetToDefault methods that dispatch custom events for PerformanceDashboard to handle. Co-authored-by: Abasz <32517724+Abasz@users.noreply.github.com> --- app/client/components/DashboardToolbar.ts | 42 ++++++++++++++++++++--- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/app/client/components/DashboardToolbar.ts b/app/client/components/DashboardToolbar.ts index 04087ae30a..d8e30d90db 100644 --- a/app/client/components/DashboardToolbar.ts +++ b/app/client/components/DashboardToolbar.ts @@ -7,7 +7,6 @@ import { AppElement, html, css, TemplateResult } from './AppElement' import { customElement, property, state } from 'lit/decorators.js' import { iconSettings, iconUndo, iconExpand, iconCompress, iconPoweroff, iconBluetooth, iconUpload, iconHeartbeat, iconAntplus } from '../lib/icons' -import './SettingsDialog' import './AppDialog' import type { AppConfig } from '../store/types' @@ -51,6 +50,11 @@ export class DashboardToolbar extends AppElement { filter: brightness(150%); } + button.active { + background: var(--theme-accent-color, #4a9eff); + filter: brightness(120%); + } + button .text { position: absolute; left: 2px; @@ -91,12 +95,25 @@ export class DashboardToolbar extends AppElement { @state() _dialog?: TemplateResult + @state() + _retileMode = false + render () { return html`
+ + ${this._retileMode + ? html` + + ` + : ''} @@ -179,9 +196,10 @@ export class DashboardToolbar extends AppElement { } openSettings () { - this._dialog = html` { - this._dialog = undefined - }}>` + this.dispatchEvent(new CustomEvent('open-settings', { + bubbles: true, + composed: true + })) } toggleFullscreen () { @@ -199,6 +217,22 @@ export class DashboardToolbar extends AppElement { this.sendEvent('triggerAction', { command: 'reset' }) } + toggleRetileMode () { + this._retileMode = !this._retileMode + this.dispatchEvent(new CustomEvent('retile-mode-changed', { + bubbles: true, + composed: true, + detail: { active: this._retileMode } + })) + } + + resetToDefault () { + this.dispatchEvent(new CustomEvent('reset-layout-to-default', { + bubbles: true, + composed: true + })) + } + switchBlePeripheralMode () { this.sendEvent('triggerAction', { command: 'switchBlePeripheralMode' }) } From 56a55a5cebb407e0a5b36550668ee96f6fdc4cdd Mon Sep 17 00:00:00 2001 From: DXCanas <9877852+DXCanas@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:35:29 +0000 Subject: [PATCH 04/10] Implement retile mode in PerformanceDashboard - Replace fixed grid layout with CSS custom properties (--grid-columns, --grid-rows) driven by gridConfig from app state. - Add retile mode state management: local metrics copy on enter, save via changeGuiSetting on submit, reset-to-default support. - Add metric controls (remove/replace dropdown) rendered as slot content. - Add 'add tile' panel with available metrics filtered by remaining slots. - Handle settings dialog and workout dialog from PerformanceDashboard. - Use component factory pattern returning (slotContent) => TemplateResult to support both retile controls and normal rendering. Co-authored-by: Abasz <32517724+Abasz@users.noreply.github.com> --- app/client/components/PerformanceDashboard.ts | 304 ++++++++++++++++-- 1 file changed, 278 insertions(+), 26 deletions(-) diff --git a/app/client/components/PerformanceDashboard.ts b/app/client/components/PerformanceDashboard.ts index 5abab45909..b4dbc85dc5 100644 --- a/app/client/components/PerformanceDashboard.ts +++ b/app/client/components/PerformanceDashboard.ts @@ -7,9 +7,11 @@ import { AppElement, html, css, TemplateResult } from './AppElement' import { customElement, property, state } from 'lit/decorators.js' import './DashboardToolbar' +import './SettingsDialog' import './WorkoutDialog' import { DASHBOARD_METRICS } from '../store/dashboardMetrics' -import type { AppState } from '../store/types' +import { APP_STATE } from '../store/appState' +import type { AppState, GridOrientationConfig } from '../store/types' @customElement('performance-dashboard') export class PerformanceDashboard extends AppElement { @@ -33,26 +35,11 @@ export class PerformanceDashboard extends AppElement { grid-area: metrics; display: grid; gap: 1vw; - grid-template-columns: repeat(4, 1fr); - grid-template-rows: repeat(2, 1fr); + grid-template-columns: repeat(var(--grid-columns, 4), 1fr); + grid-template-rows: repeat(var(--grid-rows, 2), 1fr); min-height: 0; /* prevent grid blowout */ } - .metrics-grid.rows-3 { - grid-template-rows: repeat(3, 1fr); - } - - @media (orientation: portrait) { - .metrics-grid { - grid-template-columns: repeat(2, 1fr); - grid-template-rows: repeat(4, 1fr); - } - - .metrics-grid.rows-3 { - grid-template-rows: repeat(6, 1fr); - } - } - /* This should be defined within the component */ dashboard-metric, dashboard-force-curve { @@ -63,29 +50,279 @@ export class PerformanceDashboard extends AppElement { position: relative; min-height: 0; /* prevent grid blowout */ } + + .retile-controls { + display: flex; + } + + .retile-select { + font-size: 0.4em; + background: var(--theme-button-color); + color: var(--theme-font-color); + cursor: pointer; + border: none; + border-radius: var(--theme-border-radius); + } + + .add-tile { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + background: var(--theme-widget-color); + border: 2px dashed var(--theme-font-color); + border-radius: var(--theme-border-radius); + padding: 8px; + min-height: 80px; + max-height: 100%; + overflow-y: auto; + } + + .add-tile.empty { + justify-content: center; + align-items: center; + } + + .add-tile-message { + font-size: 0.6em; + color: var(--theme-font-color); + opacity: 0.7; + } + + .add-tile-option { + display: flex; + align-items: center; + gap: 4px; + margin-bottom: 4px; + width: 100%; + } + + .add-tile-option input[type="radio"] { + cursor: pointer; + width: 1em; + height: 1em; + flex-shrink: 0; + } + + .add-tile-option label { + cursor: pointer; + font-size: 0.5em; + -webkit-user-select: none; + user-select: none; + flex: 1; + } ` @property({ type: Object }) declare appState: AppState @state() - _dialog?: TemplateResult + _retileMode = false + + @state() + _localMetrics: string[] | null = null + + @state() + _dialog: TemplateResult | null = null + + @state() + _columns = 4 + + @state() + _rows = 2 + + @state() + _maxGridSlots = 8 + + connectedCallback () { + super.connectedCallback() + this.addEventListener('retile-mode-changed', this._handleRetileModeChanged as EventListener) + this.addEventListener('reset-layout-to-default', this._handleResetToDefault) + this.addEventListener('open-settings', this._handleOpenSettings) + } + + willUpdate (changedProperties: Map) { + super.willUpdate(changedProperties) + + if (changedProperties.has('appState')) { + const oldAppState = changedProperties.get('appState') as AppState | undefined + const oldGridConfig = oldAppState?.config?.guiConfigs?.gridConfig + const newGridConfig = this.appState?.config?.guiConfigs?.gridConfig + + if (oldGridConfig !== newGridConfig) { + this._computeGridConfig() + } + } + + if (changedProperties.has('_localMetrics') && this._localMetrics) { + let currentSlots = this._localMetrics.reduce((sum, key) => sum + (DASHBOARD_METRICS[key]?.size || 1), 0) + + if (currentSlots > this._maxGridSlots) { + const newMetrics = [...this._localMetrics] + while (currentSlots > this._maxGridSlots && newMetrics.length > 0) { + const removed = newMetrics.pop()! + currentSlots -= (DASHBOARD_METRICS[removed]?.size || 1) + } + this._localMetrics = newMetrics + } + } + } + + _computeGridConfig () { + const gridConfig = this.appState?.config?.guiConfigs?.gridConfig ?? APP_STATE.config.guiConfigs.gridConfig + const isPortrait = window.matchMedia('(orientation: portrait)').matches + const orientationConfig: GridOrientationConfig = isPortrait ? gridConfig.portrait : gridConfig.landscape + + this._columns = orientationConfig.columns + this._rows = orientationConfig.rows + this._maxGridSlots = this._columns * this._rows + } + + disconnectedCallback () { + super.disconnectedCallback() + this.removeEventListener('retile-mode-changed', this._handleRetileModeChanged as EventListener) + this.removeEventListener('reset-layout-to-default', this._handleResetToDefault) + this.removeEventListener('open-settings', this._handleOpenSettings) + } + + _handleOpenSettings = () => { + this._dialog = html` + { this._dialog = null }} + > + ` + } _handleWorkoutOpen = (type: string) => { this.sendEvent('workout-open', type) this._dialog = html` { this._dialog = undefined }} + @close=${() => { this._dialog = null }} > ` } + _handleRetileModeChanged = (event: CustomEvent<{ active: boolean }>) => { + const wasActive = this._retileMode + this._retileMode = event.detail.active + + if (this._retileMode && !wasActive) { + this._localMetrics = [...this.appState.config.guiConfigs.dashboardMetrics] + } else if (!this._retileMode && wasActive) { + if (this._localMetrics) { + this.sendEvent('changeGuiSetting', { + dashboardMetrics: this._localMetrics + }) + } + this._localMetrics = null + } + } + + _removeMetric = (index: number) => { + if (!this._localMetrics) { return } + const newMetrics = [...this._localMetrics] + newMetrics.splice(index, 1) + this._localMetrics = newMetrics + } + + _handleResetToDefault = () => { + if (this._retileMode) { + this._localMetrics = [...APP_STATE.config.guiConfigs.dashboardMetrics] + } + } + + _getAvailableMetrics (availableSlots = Infinity): string[] { + const currentSet = new Set(this._localMetrics ?? []) + return Object.keys(DASHBOARD_METRICS).filter((key) => { + if (currentSet.has(key)) { return false } + const metricSize = DASHBOARD_METRICS[key]?.size || 1 + return metricSize <= availableSlots + }) + } + + _handleMetricAction = (index: number, value: string) => { + if (value === 'remove') { + this._removeMetric(index) + } else if (value) { + this._replaceMetricDirect(index, value) + } + } + + _renderMetricWithControls (componentFactory: (slotContent?: TemplateResult | string) => TemplateResult, index: number, metricName: string, availableSlots: number): TemplateResult { + const currentMetricSize = DASHBOARD_METRICS[metricName]?.size || 1 + const slotsIfReplaced = availableSlots + currentMetricSize + const availableMetrics = this._getAvailableMetrics(slotsIfReplaced) + + const controls = html` +
+ +
+ ` + + return componentFactory(controls) + } + + _renderAddTile (availableSlots: number): TemplateResult { + const availableMetrics = this._getAvailableMetrics(availableSlots) + + if (availableMetrics.length === 0) { + return html` +
+ All metrics in use or won't fit +
+ ` + } + + return html` +
+ ${availableMetrics.map((key) => html` +
+ this._addMetricDirect(key)} + /> + +
+ `)} +
+ ` + } + + _addMetricDirect = (metricKey: string) => { + if (!this._localMetrics || !metricKey) { return } + this._localMetrics = [...this._localMetrics, metricKey] + } + + _replaceMetricDirect = (index: number, metricKey: string) => { + if (!this._localMetrics || !metricKey) { return } + const newMetrics = [...this._localMetrics] + newMetrics[index] = metricKey + this._localMetrics = newMetrics + } + dashboardMetricComponentsFactory = (appState: AppState) => { const metrics = appState.metrics const configs = appState.config - const dashboardMetricComponents: Record = Object.keys(DASHBOARD_METRICS).reduce((dashboardMetrics: Record, key) => { - dashboardMetrics[key] = DASHBOARD_METRICS[key].template(metrics, configs, this._handleWorkoutOpen) + const dashboardMetricComponents: Record TemplateResult> = Object.keys(DASHBOARD_METRICS).reduce((dashboardMetrics: Record TemplateResult>, key) => { + dashboardMetrics[key] = (slotContent?: TemplateResult | string) => DASHBOARD_METRICS[key].template(metrics, configs, this._handleWorkoutOpen, slotContent) return dashboardMetrics }, {}) @@ -94,16 +331,31 @@ export class PerformanceDashboard extends AppElement { } render () { - const metricConfig = [...new Set(this.appState.config.guiConfigs.dashboardMetrics)].reduce((prev: TemplateResult[], metricName) => { - prev.push(this.dashboardMetricComponentsFactory(this.appState)[metricName]) + const metrics = this._localMetrics ?? this.appState?.config?.guiConfigs?.dashboardMetrics ?? [] + const uniqueMetrics = [...new Set(metrics)] + const currentGridSlots = uniqueMetrics.reduce((sum, key) => sum + (DASHBOARD_METRICS[key]?.size || 1), 0) + const availableSlots = this._maxGridSlots - currentGridSlots + + const metricConfig = uniqueMetrics.reduce((prev: TemplateResult[], metricName, index) => { + const componentFactory = this.dashboardMetricComponentsFactory(this.appState)[metricName] + if (this._retileMode) { + prev.push(this._renderMetricWithControls(componentFactory, index, metricName, availableSlots)) + } else { + prev.push(componentFactory()) + } return prev }, []) - const gridClass = this.appState.config.guiConfigs.maxNumberOfTiles === 12 ? 'rows-3' : '' + if (this._retileMode && currentGridSlots < this._maxGridSlots) { + metricConfig.push(this._renderAddTile(availableSlots)) + } return html` -
${metricConfig}
+
${metricConfig}
${this._dialog ? this._dialog : ''} ` } From 8afeba28e10ac79b978d7dce8f05bdc3ca87253c Mon Sep 17 00:00:00 2001 From: Abasz <32517724+Abasz@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:56:50 +0000 Subject: [PATCH 05/10] Fix retile dropdown cursor and click area - Make cursor:pointer and @click on distance/calories/timer tiles conditional on onWorkoutOpen being defined (not shown in retile mode) - Add stopPropagation to retile controls to prevent parent click handler from intercepting dropdown interactions - Increase retile select padding and min-width for larger click area - Add z-index to retile controls for reliable click targeting --- app/client/components/PerformanceDashboard.ts | 10 ++++++++-- app/client/store/dashboardMetrics.ts | 12 ++++++------ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/app/client/components/PerformanceDashboard.ts b/app/client/components/PerformanceDashboard.ts index b4dbc85dc5..9c7b8b5b85 100644 --- a/app/client/components/PerformanceDashboard.ts +++ b/app/client/components/PerformanceDashboard.ts @@ -53,10 +53,14 @@ export class PerformanceDashboard extends AppElement { .retile-controls { display: flex; + position: relative; + z-index: 10; } .retile-select { font-size: 0.4em; + padding: 4px 8px; + min-width: 80px; background: var(--theme-button-color); color: var(--theme-font-color); cursor: pointer; @@ -256,9 +260,10 @@ export class PerformanceDashboard extends AppElement { const availableMetrics = this._getAvailableMetrics(slotsIfReplaced) const controls = html` -
+
e.stopPropagation()}> { this._columns = parseInt((e.target as HTMLInputElement).value, 10) }} + /> + + +
+

Experimental settings: -