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/components/DashboardToolbar.test.ts b/app/client/components/DashboardToolbar.test.ts index a266ba5f85..780c9a9d2d 100644 --- a/app/client/components/DashboardToolbar.test.ts +++ b/app/client/components/DashboardToolbar.test.ts @@ -16,11 +16,15 @@ function createToolbar (config: Partial = {}): DashboardToolbar { uploadEnabled: false, shutdownEnabled: false, guiConfigs: { - dashboardMetrics: [], + landscapeDashboardMetrics: [], + portraitDashboardMetrics: [], showIcons: true, - maxNumberOfTiles: 8, trueBlackTheme: false, - forceCurveDivisionMode: 0 + forceCurveDivisionMode: 0, + gridConfig: { + landscape: { columns: 4, rows: 2 }, + portrait: { columns: 2, rows: 4 } + } }, ...config } @@ -92,3 +96,48 @@ describe('renderOptionalButtons', () => { expect(buttonsWithoutFullscreen.length).toBe(0) }) }) + +describe('retile button style', () => { + test('should default _retileMode to false', () => { + const toolbar = createToolbar() + expect(toolbar._retileMode).toBe(false) + }) + + test('should include label-button class on the retile toggle button', () => { + const toolbar = createToolbar() + + const result = toolbar.render() + + const staticParts = (result as unknown as { strings: readonly string[] }).strings.join('') + expect(staticParts).toContain('label-button') + }) + + test('should include label-button class on the submit button in retile mode', () => { + const toolbar = createToolbar() + toolbar._retileMode = true + + const result = toolbar.render() + + const staticParts = (result as unknown as { strings: readonly string[] }).strings.join('') + expect(staticParts).toContain('label-button') + }) + + test('should not include active class on the retile button in normal mode', () => { + const toolbar = createToolbar() + + const result = toolbar.render() + + const staticParts = (result as unknown as { strings: readonly string[] }).strings.join('') + expect(staticParts).not.toContain('active') + }) + + test('should not include active class on the submit button in retile mode', () => { + const toolbar = createToolbar() + toolbar._retileMode = true + + const result = toolbar.render() + + const staticParts = (result as unknown as { strings: readonly string[] }).strings.join('') + expect(staticParts).not.toContain('active') + }) +}) diff --git a/app/client/components/DashboardToolbar.ts b/app/client/components/DashboardToolbar.ts index 04087ae30a..8f55511a05 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,16 @@ export class DashboardToolbar extends AppElement { filter: brightness(150%); } + button.active { + background: var(--theme-accent-color, #4a9eff); + filter: brightness(120%); + } + + button.label-button { + width: auto; + padding: 0 0.6em; + } + button .text { position: absolute; left: 2px; @@ -91,12 +100,25 @@ export class DashboardToolbar extends AppElement { @state() _dialog?: TemplateResult + @state() + _retileMode = false + render () { return html`
+ + ${this._retileMode ? + html` + + ` : + ''} @@ -179,9 +201,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 +222,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' }) } diff --git a/app/client/components/PerformanceDashboard.styles.ts b/app/client/components/PerformanceDashboard.styles.ts new file mode 100644 index 0000000000..e5bbed2aec --- /dev/null +++ b/app/client/components/PerformanceDashboard.styles.ts @@ -0,0 +1,109 @@ +/* + Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor + + Styles for the PerformanceDashboard component +*/ + +import { css } from './AppElement' + +export const performanceDashboardStyles = css` + :host { + display: grid; + grid-template: + "toolbar" auto + "metrics" 1fr + / 1fr; + height: 100vh; + gap: 1vw; + box-sizing: border-box; + } + + dashboard-toolbar { + grid-area: toolbar; + } + + .metrics-grid { + grid-area: metrics; + display: grid; + gap: 1vw; + grid-template-columns: repeat(var(--grid-columns, 4), 1fr); + grid-template-rows: repeat(var(--grid-rows, 2), 1fr); + min-height: 0; /* prevent grid blowout */ + } + + /* This should be defined within the component */ + dashboard-metric, + dashboard-force-curve { + background: var(--theme-widget-color); + text-align: center; + padding: 0.2em; + border-radius: var(--theme-border-radius); + position: relative; + min-height: 0; /* prevent grid blowout */ + } + + .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; + 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; + } +` diff --git a/app/client/components/PerformanceDashboard.test.ts b/app/client/components/PerformanceDashboard.test.ts index 1bc41f08cd..108d676710 100644 --- a/app/client/components/PerformanceDashboard.test.ts +++ b/app/client/components/PerformanceDashboard.test.ts @@ -2,8 +2,9 @@ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor */ // @vitest-environment happy-dom -import { test, expect, describe } from 'vitest' +import { test, expect, describe, vi, beforeEach, afterEach } from 'vitest' +import type { TemplateResult } from 'lit' import { PerformanceDashboard } from './PerformanceDashboard' import { DASHBOARD_METRICS } from '../store/dashboardMetrics' import { APP_STATE } from '../store/appState' @@ -24,18 +25,381 @@ describe('dashboardMetricComponentsFactory', () => { }) }) -describe('grid class', () => { - test('should return "rows-3" when maxNumberOfTiles is 12', () => { +describe('_computeGridConfig', () => { + function mockMatchMedia (isPortrait: boolean) { + vi.spyOn(window, 'matchMedia').mockReturnValue({ + matches: isPortrait, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + } as unknown as MediaQueryList) + } + + afterEach(() => { + vi.restoreAllMocks() + }) + + test('should set columns and rows from landscape config when not in portrait', () => { + mockMatchMedia(false) + const dashboard = createDashboard() + dashboard.appState.config.guiConfigs.gridConfig = { + landscape: { columns: 5, rows: 3 }, + portrait: { columns: 2, rows: 6 } + } + dashboard._computeGridConfig() + expect(dashboard._columns).toBe(5) + expect(dashboard._rows).toBe(3) + expect(dashboard._maxGridSlots).toBe(15) + }) + + test('should set columns and rows from portrait config when in portrait', () => { + mockMatchMedia(true) + const dashboard = createDashboard() + dashboard.appState.config.guiConfigs.gridConfig = { + landscape: { columns: 5, rows: 3 }, + portrait: { columns: 2, rows: 6 } + } + dashboard._computeGridConfig() + expect(dashboard._columns).toBe(2) + expect(dashboard._rows).toBe(6) + expect(dashboard._maxGridSlots).toBe(12) + }) +}) + +describe('orientation change listener', () => { + let addListenerSpy: ReturnType + let removeListenerSpy: ReturnType + let mockMql: MediaQueryList + + beforeEach(() => { + addListenerSpy = vi.fn() + removeListenerSpy = vi.fn() + mockMql = { + matches: false, + addEventListener: addListenerSpy, + removeEventListener: removeListenerSpy + } as unknown as MediaQueryList + vi.spyOn(window, 'matchMedia').mockReturnValue(mockMql) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + test('should register an orientation change listener on connectedCallback', () => { const dashboard = createDashboard() - dashboard.appState.config.guiConfigs.maxNumberOfTiles = 12 - const gridClass = dashboard.appState.config.guiConfigs.maxNumberOfTiles === 12 ? 'rows-3' : '' - expect(gridClass).toBe('rows-3') + dashboard.connectedCallback() + expect(addListenerSpy).toHaveBeenCalledWith('change', dashboard._handleOrientationChange) + }) + + test('should remove the orientation change listener on disconnectedCallback', () => { + const dashboard = createDashboard() + dashboard.connectedCallback() + dashboard.disconnectedCallback() + expect(removeListenerSpy).toHaveBeenCalledWith('change', dashboard._handleOrientationChange) + }) +}) + +describe('_getAvailableMetrics', () => { + test('should exclude metrics already in the list', () => { + const dashboard = createDashboard() + dashboard._localMetrics = ['distance'] + const result = dashboard._getAvailableMetrics() + expect(result).not.toContain('distance') + }) + + test('should exclude metrics whose size exceeds available slots', () => { + const dashboard = createDashboard() + dashboard._localMetrics = ['distance'] + // availableSlots = 1, forceCurve has size 2 → should be excluded + const result = dashboard._getAvailableMetrics(1) + expect(result).not.toContain('forceCurve') + }) + + test('should include size-2 metrics when enough slots are available', () => { + const dashboard = createDashboard() + dashboard._localMetrics = ['distance', 'timer'] + const result = dashboard._getAvailableMetrics(2) + expect(result).toContain('forceCurve') + }) +}) + +describe('_getOrientationMetrics', () => { + function mockMatchMedia (isPortrait: boolean) { + vi.spyOn(window, 'matchMedia').mockReturnValue({ + matches: isPortrait, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + } as unknown as MediaQueryList) + } + + afterEach(() => { + vi.restoreAllMocks() + }) + + test('should return landscapeDashboardMetrics when in landscape', () => { + mockMatchMedia(false) + const dashboard = createDashboard() + dashboard.appState.config.guiConfigs.landscapeDashboardMetrics = ['distance', 'pace'] + dashboard.appState.config.guiConfigs.portraitDashboardMetrics = ['timer', 'power'] + expect(dashboard._getOrientationMetrics()).toEqual(['distance', 'pace']) + }) + + test('should return portraitDashboardMetrics when in portrait', () => { + mockMatchMedia(true) + const dashboard = createDashboard() + dashboard.appState.config.guiConfigs.landscapeDashboardMetrics = ['distance', 'pace'] + dashboard.appState.config.guiConfigs.portraitDashboardMetrics = ['timer', 'power'] + expect(dashboard._getOrientationMetrics()).toEqual(['timer', 'power']) + }) +}) + +describe('_handleRetileModeChanged', () => { + function mockMatchMedia (isPortrait: boolean) { + vi.spyOn(window, 'matchMedia').mockReturnValue({ + matches: isPortrait, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + } as unknown as MediaQueryList) + } + + afterEach(() => { + vi.restoreAllMocks() + }) + + test('should copy orientation metrics into _localMetrics when activated', () => { + mockMatchMedia(false) + const dashboard = createDashboard() + dashboard.appState.config.guiConfigs.landscapeDashboardMetrics = ['distance', 'pace'] + dashboard._retileMode = false + dashboard._handleRetileModeChanged(new CustomEvent('retile-mode-changed', { detail: { active: true } })) + expect(dashboard._localMetrics).toEqual(['distance', 'pace']) + }) + + test('should dispatch changeGuiSetting with landscapeDashboardMetrics key when deactivated in landscape', () => { + mockMatchMedia(false) + const dashboard = createDashboard() + dashboard._retileMode = true + dashboard._localMetrics = ['distance', 'timer', 'pace'] + + let received: CustomEvent | undefined + dashboard.addEventListener('changeGuiSetting', (e) => { received = e as CustomEvent }) + + dashboard._handleRetileModeChanged(new CustomEvent('retile-mode-changed', { detail: { active: false } })) + + expect(received!.detail).toEqual({ landscapeDashboardMetrics: ['distance', 'timer', 'pace'] }) + expect(dashboard._localMetrics).toBeNull() + }) + + test('should dispatch changeGuiSetting with portraitDashboardMetrics key when deactivated in portrait', () => { + mockMatchMedia(true) + const dashboard = createDashboard() + dashboard._retileMode = true + dashboard._localMetrics = ['timer', 'power'] + + let received: CustomEvent | undefined + dashboard.addEventListener('changeGuiSetting', (e) => { received = e as CustomEvent }) + + dashboard._handleRetileModeChanged(new CustomEvent('retile-mode-changed', { detail: { active: false } })) + + expect(received!.detail).toEqual({ portraitDashboardMetrics: ['timer', 'power'] }) + expect(dashboard._localMetrics).toBeNull() + }) +}) + +describe('_handleResetToDefault', () => { + function mockMatchMedia (isPortrait: boolean) { + vi.spyOn(window, 'matchMedia').mockReturnValue({ + matches: isPortrait, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + } as unknown as MediaQueryList) + } + + afterEach(() => { + vi.restoreAllMocks() }) - test('should return empty string when maxNumberOfTiles is 8', () => { + test('should reset _localMetrics to landscape defaults when in landscape', () => { + mockMatchMedia(false) + const dashboard = createDashboard() + dashboard._retileMode = true + dashboard._localMetrics = ['distance'] + dashboard._handleResetToDefault() + expect(dashboard._localMetrics).toEqual(APP_STATE.config.guiConfigs.landscapeDashboardMetrics) + }) + + test('should reset _localMetrics to portrait defaults when in portrait', () => { + mockMatchMedia(true) + const dashboard = createDashboard() + dashboard._retileMode = true + dashboard._localMetrics = ['distance'] + dashboard._handleResetToDefault() + expect(dashboard._localMetrics).toEqual(APP_STATE.config.guiConfigs.portraitDashboardMetrics) + }) + + test('should not modify _localMetrics when retile mode is not active', () => { + mockMatchMedia(false) + const dashboard = createDashboard() + dashboard._retileMode = false + dashboard._localMetrics = ['distance'] + dashboard._handleResetToDefault() + expect(dashboard._localMetrics).toEqual(['distance']) + }) +}) + +describe('_removeMetric', () => { + test('should remove the metric at the given index', () => { + const dashboard = createDashboard() + dashboard._localMetrics = ['distance', 'timer', 'pace'] + dashboard._removeMetric(1) + expect(dashboard._localMetrics).toEqual(['distance', 'pace']) + }) + + test('should not modify state when _localMetrics is null', () => { + const dashboard = createDashboard() + dashboard._localMetrics = null + dashboard._removeMetric(0) + expect(dashboard._localMetrics).toBeNull() + }) +}) + +describe('_addMetricDirect', () => { + test('should append the metric to _localMetrics', () => { + const dashboard = createDashboard() + dashboard._localMetrics = ['distance'] + dashboard._addMetricDirect('timer') + expect(dashboard._localMetrics).toEqual(['distance', 'timer']) + }) + + test('should not modify state when _localMetrics is null', () => { + const dashboard = createDashboard() + dashboard._localMetrics = null + dashboard._addMetricDirect('timer') + expect(dashboard._localMetrics).toBeNull() + }) +}) + +describe('_replaceMetricDirect', () => { + test('should replace the metric at the given index', () => { + const dashboard = createDashboard() + dashboard._localMetrics = ['distance', 'timer', 'pace'] + dashboard._replaceMetricDirect(1, 'power') + expect(dashboard._localMetrics).toEqual(['distance', 'power', 'pace']) + }) + + test('should not modify state when _localMetrics is null', () => { + const dashboard = createDashboard() + dashboard._localMetrics = null + dashboard._replaceMetricDirect(0, 'timer') + expect(dashboard._localMetrics).toBeNull() + }) +}) + +describe('render', () => { + function mockMatchMedia (isPortrait: boolean) { + vi.spyOn(window, 'matchMedia').mockReturnValue({ + matches: isPortrait, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + } as unknown as MediaQueryList) + } + + afterEach(() => { + vi.restoreAllMocks() + }) + + test('should apply --grid-columns and --grid-rows CSS variables from _columns and _rows', () => { + mockMatchMedia(false) + const dashboard = createDashboard() + dashboard._columns = 3 + dashboard._rows = 5 + dashboard._localMetrics = [] + const result = dashboard.render() + expect(result.values).toContain(3) + expect(result.values).toContain(5) + }) + + test('should render a tile for each metric in _localMetrics when set', () => { + mockMatchMedia(false) + const dashboard = createDashboard() + dashboard._retileMode = false + dashboard._localMetrics = ['distance', 'timer', 'pace'] + const result = dashboard.render() + const metricConfig = result.values[3] as TemplateResult[] + expect(metricConfig).toHaveLength(3) + }) + + test('should render tiles from orientation metrics when _localMetrics is null', () => { + mockMatchMedia(false) + const dashboard = createDashboard() + dashboard._retileMode = false + dashboard._localMetrics = null + const orientationMetrics = dashboard._getOrientationMetrics() + const result = dashboard.render() + const metricConfig = result.values[3] as TemplateResult[] + expect(metricConfig).toHaveLength(orientationMetrics.length) + }) + + test('should append add-tile when in retile mode and grid has available slots', () => { + mockMatchMedia(false) + const dashboard = createDashboard() + dashboard._retileMode = true + dashboard._localMetrics = ['distance'] + dashboard._maxGridSlots = 8 + const result = dashboard.render() + const metricConfig = result.values[3] as TemplateResult[] + // 1 metric tile wrapped with controls + 1 add-tile + expect(metricConfig).toHaveLength(2) + }) + + test('should not append add-tile when retile mode is off', () => { + mockMatchMedia(false) + const dashboard = createDashboard() + dashboard._retileMode = false + dashboard._localMetrics = ['distance'] + dashboard._maxGridSlots = 8 + const result = dashboard.render() + const metricConfig = result.values[3] as TemplateResult[] + expect(metricConfig).toHaveLength(1) + }) + + test('should not append add-tile when the grid is full', () => { + mockMatchMedia(false) + const dashboard = createDashboard() + dashboard._retileMode = true + dashboard._localMetrics = ['distance', 'timer', 'pace'] + dashboard._maxGridSlots = 3 + const result = dashboard.render() + const metricConfig = result.values[3] as TemplateResult[] + expect(metricConfig).toHaveLength(3) + }) +}) + +describe('_renderAddTile', () => { + test('should render empty message when no metrics fit in available slots', () => { + const dashboard = createDashboard() + dashboard._localMetrics = [] + const result = dashboard._renderAddTile(0) + expect(result.values).toHaveLength(0) + expect([...result.strings].join('')).toContain('All metrics in use or won\'t fit') + }) + + test('should render one option per available metric', () => { + const dashboard = createDashboard() + dashboard._localMetrics = [] + const result = dashboard._renderAddTile(Infinity) + const options = result.values[0] as TemplateResult[] + expect(options).toHaveLength(Object.keys(DASHBOARD_METRICS).length) + }) +}) + +describe('_renderMetricWithControls', () => { + test('should pass controls slot content to the component factory', () => { const dashboard = createDashboard() - dashboard.appState.config.guiConfigs.maxNumberOfTiles = 8 - const gridClass = dashboard.appState.config.guiConfigs.maxNumberOfTiles === 12 ? 'rows-3' : '' - expect(gridClass).toBe('') + dashboard._localMetrics = ['distance'] + const factory = vi.fn() as unknown as (slotContent?: TemplateResult | string) => TemplateResult + dashboard._renderMetricWithControls(factory, 0, 'distance', 5) + expect(vi.mocked(factory)).toHaveBeenCalledOnce() + expect(vi.mocked(factory).mock.calls[0][0]).toBeDefined() }) }) diff --git a/app/client/components/PerformanceDashboard.ts b/app/client/components/PerformanceDashboard.ts index 5abab45909..5ced58c310 100644 --- a/app/client/components/PerformanceDashboard.ts +++ b/app/client/components/PerformanceDashboard.ts @@ -4,88 +4,240 @@ Component that renders the dashboard */ -import { AppElement, html, css, TemplateResult } from './AppElement' +import { AppElement, html, TemplateResult } from './AppElement' +import { performanceDashboardStyles } from './PerformanceDashboard.styles' 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 { - static styles = css` - :host { - display: grid; - grid-template: - "toolbar" auto - "metrics" 1fr - / 1fr; - height: 100vh; - gap: 1vw; - box-sizing: border-box; - } + static styles = performanceDashboardStyles - dashboard-toolbar { - grid-area: toolbar; - } + @property({ type: Object }) + declare appState: AppState - .metrics-grid { - grid-area: metrics; - display: grid; - gap: 1vw; - grid-template-columns: repeat(4, 1fr); - grid-template-rows: repeat(2, 1fr); - min-height: 0; /* prevent grid blowout */ - } + @state() + _retileMode = false + + @state() + _localMetrics: string[] | null = null + + @state() + _dialog: TemplateResult | null = null + + @state() + _columns = 4 + + @state() + _rows = 2 - .metrics-grid.rows-3 { - grid-template-rows: repeat(3, 1fr); + @state() + _maxGridSlots = 8 + + _orientationMediaQuery: MediaQueryList | null = null + _handleOrientationChange = () => { + this._computeGridConfig() + if (this._retileMode) { + this._localMetrics = this._getOrientationMetrics() } + } - @media (orientation: portrait) { - .metrics-grid { - grid-template-columns: repeat(2, 1fr); - grid-template-rows: repeat(4, 1fr); - } + 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) + this._orientationMediaQuery = window.matchMedia('(orientation: portrait)') + this._orientationMediaQuery.addEventListener('change', this._handleOrientationChange) + } + + 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 - .metrics-grid.rows-3 { - grid-template-rows: repeat(6, 1fr); + if (oldGridConfig !== newGridConfig) { + this._computeGridConfig() } } + } - /* This should be defined within the component */ - dashboard-metric, - dashboard-force-curve { - background: var(--theme-widget-color); - text-align: center; - padding: 0.2em; - border-radius: var(--theme-border-radius); - position: relative; - min-height: 0; /* prevent grid blowout */ - } - ` - @property({ type: Object }) - declare appState: AppState + _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 - @state() - _dialog?: TemplateResult + this._columns = orientationConfig.columns + this._rows = orientationConfig.rows + this._maxGridSlots = this._columns * this._rows + } + + _getOrientationMetrics (): string[] { + const isPortrait = window.matchMedia('(orientation: portrait)').matches + const guiConfigs = this.appState?.config?.guiConfigs ?? APP_STATE.config.guiConfigs + return isPortrait ? guiConfigs.portraitDashboardMetrics : guiConfigs.landscapeDashboardMetrics + } + + 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) + this._orientationMediaQuery?.removeEventListener('change', this._handleOrientationChange) + } + + _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._getOrientationMetrics()] + } else if (!this._retileMode && wasActive) { + if (this._localMetrics) { + const isPortrait = window.matchMedia('(orientation: portrait)').matches + const metricsKey = isPortrait ? 'portraitDashboardMetrics' : 'landscapeDashboardMetrics' + this.sendEvent('changeGuiSetting', { [metricsKey]: 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) { + const isPortrait = window.matchMedia('(orientation: portrait)').matches + const defaultMetrics = APP_STATE.config.guiConfigs + this._localMetrics = isPortrait ? + [...defaultMetrics.portraitDashboardMetrics] : + [...defaultMetrics.landscapeDashboardMetrics] + } + } + + _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` +
e.stopPropagation()}> + +
+ ` + + 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 workoutHandler = this._retileMode ? undefined : this._handleWorkoutOpen - 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, workoutHandler, slotContent) return dashboardMetrics }, {}) @@ -94,16 +246,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._getOrientationMetrics() + 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 : ''} ` } diff --git a/app/client/components/SettingsDialog.test.ts b/app/client/components/SettingsDialog.test.ts index c9ed7db505..f9b39551b9 100644 --- a/app/client/components/SettingsDialog.test.ts +++ b/app/client/components/SettingsDialog.test.ts @@ -2,76 +2,82 @@ Open Rowing Monitor, https://github.com/JaapvanEkris/openrowingmonitor */ // @vitest-environment happy-dom -import { test, expect, describe } from 'vitest' +import { test, expect, describe, vi, afterEach } from 'vitest' // The class is exported as DashboardActions (naming bug in source) import { DashboardActions as SettingsDialog } from './SettingsDialog' +function mockMatchMedia (isPortrait: boolean) { + vi.spyOn(window, 'matchMedia').mockReturnValue({ + matches: isPortrait, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + } as unknown as MediaQueryList) +} + function createSettingsDialog (): SettingsDialog { const dialog = new SettingsDialog() dialog.config = { - dashboardMetrics: [], + landscapeDashboardMetrics: [], + portraitDashboardMetrics: [], showIcons: true, - maxNumberOfTiles: 8, trueBlackTheme: false, - forceCurveDivisionMode: 0 + forceCurveDivisionMode: 0, + gridConfig: { + landscape: { columns: 4, rows: 2 }, + portrait: { columns: 2, rows: 4 } + } } return dialog } -describe('isFormValid', () => { - test('should return true when slot count equals maxNumberOfTiles and no adjacent duplicates', () => { - const dialog = createSettingsDialog() - dialog._maxNumberOfTiles = 8 - dialog._sumSelectedSlots = 8 - dialog._selectedMetrics = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] - - expect(dialog.isFormValid()).toBe(true) - }) +afterEach(() => { + vi.restoreAllMocks() +}) - test('should return false when slot count does not equal maxNumberOfTiles', () => { +describe('grid sliders', () => { + test('should initialise _columns and _rows from landscape config when in landscape', () => { + mockMatchMedia(false) const dialog = createSettingsDialog() - dialog._maxNumberOfTiles = 8 - dialog._sumSelectedSlots = 6 - dialog._selectedMetrics = ['a', 'b', 'c', 'd', 'e', 'f'] - - expect(dialog.isFormValid()).toBe(false) + dialog._columns = 4 + dialog._rows = 2 + expect(dialog._columns).toBe(4) + expect(dialog._rows).toBe(2) }) - test('should return false when metrics at positions 3 and 4 are identical', () => { + test('should initialise _columns and _rows from portrait config when in portrait', () => { + mockMatchMedia(true) const dialog = createSettingsDialog() - dialog._maxNumberOfTiles = 8 - dialog._sumSelectedSlots = 8 - dialog._selectedMetrics = ['a', 'b', 'c', 'same', 'same', 'f', 'g', 'h'] - - expect(dialog.isFormValid()).toBe(false) + dialog._columns = 2 + dialog._rows = 4 + expect(dialog._columns).toBe(2) + expect(dialog._rows).toBe(4) }) - test('should return false when metrics at positions 7 and 8 are identical', () => { + test('should swap _columns and _rows and update _isPortrait on orientation change', () => { const dialog = createSettingsDialog() - dialog._maxNumberOfTiles = 12 - dialog._sumSelectedSlots = 12 - dialog._selectedMetrics = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'same', 'same', 'j', 'k', 'l'] - - expect(dialog.isFormValid()).toBe(false) - }) + dialog._columns = 4 + dialog._rows = 2 + dialog._isPortrait = false - test('should return true for 12 tiles with no adjacent duplicates at row boundaries', () => { - const dialog = createSettingsDialog() - dialog._maxNumberOfTiles = 12 - dialog._sumSelectedSlots = 12 - dialog._selectedMetrics = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l'] + const event = new Event('change') as unknown as MediaQueryListEvent + Object.defineProperty(event, 'matches', { value: true }) + dialog._handleOrientationChange(event) - expect(dialog.isFormValid()).toBe(true) + expect(dialog._columns).toBe(2) + expect(dialog._rows).toBe(4) + expect(dialog._isPortrait).toBe(true) }) }) describe('close', () => { - test('should dispatch changeGuiSetting with selected settings when confirmed', () => { + test('should save landscape and transposed portrait when confirmed in landscape', () => { + mockMatchMedia(false) const dialog = createSettingsDialog() - dialog._selectedMetrics = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] + dialog._isPortrait = false + dialog._columns = 3 + dialog._rows = 5 dialog._showIcons = false - dialog._maxNumberOfTiles = 8 dialog._trueBlackTheme = true let received: CustomEvent | undefined @@ -79,16 +85,42 @@ describe('close', () => { dialog.close(new CustomEvent('close', { detail: 'confirm' })) - expect(received).toBeDefined() expect(received!.detail).toEqual({ - dashboardMetrics: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'], + gridConfig: { + landscape: { columns: 3, rows: 5 }, + portrait: { columns: 5, rows: 3 } + }, showIcons: false, - maxNumberOfTiles: 8, trueBlackTheme: true }) }) + test('should save portrait and transposed landscape when confirmed in portrait', () => { + mockMatchMedia(true) + const dialog = createSettingsDialog() + dialog._isPortrait = true + dialog._columns = 2 + dialog._rows = 4 + dialog._showIcons = true + dialog._trueBlackTheme = false + + let received: CustomEvent | undefined + dialog.addEventListener('changeGuiSetting', (e) => { received = e as CustomEvent }) + + dialog.close(new CustomEvent('close', { detail: 'confirm' })) + + expect(received!.detail).toEqual({ + gridConfig: { + landscape: { columns: 4, rows: 2 }, + portrait: { columns: 2, rows: 4 } + }, + showIcons: true, + trueBlackTheme: false + }) + }) + test('should not dispatch changeGuiSetting when cancelled', () => { + mockMatchMedia(false) const dialog = createSettingsDialog() let received: CustomEvent | undefined dialog.addEventListener('changeGuiSetting', (e) => { received = e as CustomEvent }) @@ -98,3 +130,76 @@ describe('close', () => { expect(received).toBeUndefined() }) }) + +describe('toggleIcons', () => { + test('should set _showIcons to the checkbox checked state', () => { + const dialog = createSettingsDialog() + dialog._showIcons = true + const event = new Event('change') + Object.defineProperty(event, 'target', { value: { checked: false } }) + dialog.toggleIcons(event) + expect(dialog._showIcons).toBe(false) + }) +}) + +describe('toggleTrueBlackTheme', () => { + test('should set _trueBlackTheme to the checkbox checked state', () => { + const dialog = createSettingsDialog() + dialog._trueBlackTheme = false + const event = new Event('change') + Object.defineProperty(event, 'target', { value: { checked: true } }) + dialog.toggleTrueBlackTheme(event) + expect(dialog._trueBlackTheme).toBe(true) + }) +}) + +describe('render', () => { + test('should show landscape in legend when _isPortrait is false', () => { + const dialog = createSettingsDialog() + dialog._isPortrait = false + const result = dialog.render() + expect(result.values[3]).toBe('landscape') + }) + + test('should show portrait in legend when _isPortrait is true', () => { + const dialog = createSettingsDialog() + dialog._isPortrait = true + const result = dialog.render() + expect(result.values[3]).toBe('portrait') + }) + + test('should set columns max to 8 and rows max to 4 in landscape', () => { + const dialog = createSettingsDialog() + dialog._isPortrait = false + const result = dialog.render() + expect(result.values[5]).toBe('8') + expect(result.values[9]).toBe('4') + }) + + test('should set columns max to 4 and rows max to 8 in portrait', () => { + const dialog = createSettingsDialog() + dialog._isPortrait = true + const result = dialog.render() + expect(result.values[5]).toBe('4') + expect(result.values[9]).toBe('8') + }) +}) + +describe('orientation change listener', () => { + test('should register orientation change listener in connectedCallback', () => { + mockMatchMedia(false) + const dialog = createSettingsDialog() + const mqList = window.matchMedia('(orientation: portrait)') + dialog.connectedCallback() + expect(mqList.addEventListener).toHaveBeenCalledWith('change', dialog._handleOrientationChange) + }) + + test('should remove orientation change listener in disconnectedCallback', () => { + mockMatchMedia(false) + const dialog = createSettingsDialog() + dialog.connectedCallback() + const mqList = window.matchMedia('(orientation: portrait)') + dialog.disconnectedCallback() + expect(mqList.removeEventListener).toHaveBeenCalledWith('change', dialog._handleOrientationChange) + }) +}) diff --git a/app/client/components/SettingsDialog.ts b/app/client/components/SettingsDialog.ts index 4dd9f0ef18..91b1996c06 100644 --- a/app/client/components/SettingsDialog.ts +++ b/app/client/components/SettingsDialog.ts @@ -5,54 +5,57 @@ */ import { AppElement, html, css } from './AppElement' -import { customElement, property, query, queryAll, state } from 'lit/decorators.js' +import { customElement, property, query, state } from 'lit/decorators.js' import { iconSettings } from '../lib/icons' import './AppDialog' -import { DASHBOARD_METRICS } from '../store/dashboardMetrics' import type { GuiConfig } from '../store/types' @customElement('settings-dialog') export class DashboardActions extends AppElement { static styles = css` - .metric-selector-feedback{ - font-size: 0.5em; - padding-top: 8px; - } - - .settings-dialog>div.metric-selector{ - display: grid; - grid-template-columns: repeat(3,max-content); - gap: 8px; + .settings-dialog { + display: flex; + flex-direction: column; + gap: 12px; } - .experimental-settings { + .grid-config { display: flex; flex-direction: column; + gap: 4px; } - .experimental-settings label { - width: fit-content; - margin-top: 8px; - font-size: 0.7em; + .grid-config fieldset { + border: 1px solid var(--theme-font-color); + border-radius: var(--theme-border-radius); + padding: 6px 10px; } - .experimental-settings label>input { - font-size: 0.7em; + .grid-config legend { + font-size: 0.6em; + text-align: left; } - .settings-dialog>div>label{ + .grid-config label { + display: flex; + flex-direction: column; font-size: 0.6em; - width: fit-content; + margin-top: 6px; + } + + .grid-config input[type="range"] { + width: 100%; + cursor: pointer; } - input[type="checkbox"]{ + input[type="checkbox"] { cursor: pointer; align-self: center; width: 1.5em; height: 1.5em; } - label>span { + label > span { cursor: pointer; -webkit-user-select: none; user-select: none; @@ -62,35 +65,24 @@ export class DashboardActions extends AppElement { height: 1.6em; } - legend{ + .dialog-title { text-align: center; } - table { - min-height: 70px; - margin-top: 8px; - width: 100%; - } - - table, th, td { - font-size: 0.9em; - border: 1px solid white; - border-collapse: collapse; - } - - tr { - height: 50%; + .show-icons-selector { + display: flex; + gap: 8px; } - th, td { - padding: 8px; - text-align: center; - background-color: var(--theme-widget-color); + .experimental-settings { + display: flex; + flex-direction: column; } - .show-icons-selector { - display: flex; - gap: 8px; + .experimental-settings label { + width: fit-content; + margin-top: 8px; + font-size: 0.7em; } app-dialog > *:last-child { @@ -101,50 +93,71 @@ export class DashboardActions extends AppElement { @property({ type: Object }) config!: GuiConfig - @queryAll('.metric-selector input') - _inputs!: NodeListOf - @query('input[name="showIcons"]') _showIconInput!: HTMLInputElement - @query('input[name="maxNumberOfTiles"]') - _maxNumberOfTilesInput!: HTMLInputElement - @query('input[name="trueBlackTheme"]') _trueBlackThemeInput!: HTMLInputElement @state() - _selectedMetrics: string[] = [] + _columns = 4 @state() - _sumSelectedSlots = 0 + _rows = 2 @state() - _isValid = false + _isPortrait = false @state() _showIcons = true - @state() - _maxNumberOfTiles = 8 - @state() _trueBlackTheme = false + _orientationMediaQuery: MediaQueryList | null = null + _handleOrientationChange = (e: MediaQueryListEvent) => { + ;[this._columns, this._rows] = [this._rows, this._columns] + this._isPortrait = e.matches + } + + connectedCallback () { + super.connectedCallback() + this._orientationMediaQuery = window.matchMedia('(orientation: portrait)') + this._orientationMediaQuery.addEventListener('change', this._handleOrientationChange) + } + + disconnectedCallback () { + super.disconnectedCallback() + this._orientationMediaQuery?.removeEventListener('change', this._handleOrientationChange) + } + render () { return html` - - ${iconSettings}
Settings
- -

Select metrics to be shown:

-
- ${this.renderAvailableMetricList()} -
-
Slots remaining: ${this._maxNumberOfTiles - this._sumSelectedSlots} - - ${this.renderSelectedMetrics()} -
+ +

${iconSettings}
Settings

+ +
+
+ Grid layout (${this._isPortrait ? 'portrait' : 'landscape'}) + + +
+

Experimental settings: -