+ {state.probeMessage &&
{state.probeMessage}
}
{state.connected && (
-
+
{state.lastSyncAt
? t('dawarich.lastSync', { when: new Date(state.lastSyncAt).toLocaleString(locale) })
: t('dawarich.neverSynced')}
@@ -174,17 +168,17 @@ function DawarichConnectionStatus({
)}
{state.lastSyncError && (
-
{state.lastSyncError}
+
{state.lastSyncError}
)}
{state.capabilities?.serverVersion && (
-
+
{t('dawarich.serverVersion', { version: state.capabilities.serverVersion })}
)}
{missing.length > 0 && (
-
+
{t('dawarich.capability.missing', { features: missing.join(', ') })}
)}
diff --git a/client/src/components/Settings/DisplaySettingsTab.test.tsx b/client/src/components/Settings/DisplaySettingsTab.test.tsx
index dce07d37e..4e13ce908 100644
--- a/client/src/components/Settings/DisplaySettingsTab.test.tsx
+++ b/client/src/components/Settings/DisplaySettingsTab.test.tsx
@@ -10,6 +10,11 @@ import { buildUser, buildSettings } from '../../../tests/helpers/factories';
import DisplaySettingsTab from './DisplaySettingsTab';
import { ToastContainer } from '../shared/Toast';
+/** The switch of a yes-or-no row, named by the row's label. */
+function toggleFor(label: RegExp): HTMLElement {
+ return screen.getByRole('button', { name: label });
+}
+
beforeEach(() => {
resetAllStores();
server.use(
@@ -64,11 +69,8 @@ describe('DisplaySettingsTab', () => {
it('FE-COMP-DISPLAY-016: active language button is visually highlighted', () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }) });
render(
);
- // Multiple elements contain "English" (desktop grid button + mobile dropdown trigger).
- // The desktop grid button is the one with the active border style.
- const englishMatches = screen.getAllByText('English').map(el => el.closest('button')!).filter(Boolean);
- const activeBtn = englishMatches.find(btn => (btn.style.border || '').includes('var(--text-primary)'));
- expect(activeBtn).toBeDefined();
+ // "English" is on the chip and on the compact trigger; only the chip is a pressed choice.
+ expect(screen.getByRole('button', { name: 'English', pressed: true })).toBeInTheDocument();
});
it('FE-COMP-DISPLAY-017: shows Temperature section label', () => {
@@ -80,7 +82,7 @@ describe('DisplaySettingsTab', () => {
seedStore(useSettingsStore, { settings: buildSettings({ temperature_unit: 'celsius' }) });
render(
);
const celsiusBtn = screen.getByText('°C Celsius').closest('button')!;
- expect(celsiusBtn.style.border).toContain('var(--text-primary)');
+ expect(celsiusBtn).toHaveAttribute('aria-pressed', 'true');
});
it('FE-COMP-DISPLAY-019: clicking fahrenheit button calls updateSetting with fahrenheit', async () => {
@@ -96,7 +98,7 @@ describe('DisplaySettingsTab', () => {
seedStore(useSettingsStore, { settings: { temperature_unit: 'celsius' } });
render(
);
const metricBtn = screen.getByText('km Metric').closest('button')!;
- expect(metricBtn.style.border).toContain('var(--text-primary)');
+ expect(metricBtn).toHaveAttribute('aria-pressed', 'true');
});
it('FE-COMP-DISPLAY-029: clicking imperial distance calls updateSetting with imperial', async () => {
@@ -124,38 +126,33 @@ describe('DisplaySettingsTab', () => {
expect(screen.getByText(/blur booking codes/i)).toBeInTheDocument();
});
- it('FE-COMP-DISPLAY-025: blur booking codes On button is active when blur_booking_codes is true', () => {
+ it('FE-COMP-DISPLAY-025: blur booking codes switch is on when blur_booking_codes is true', () => {
seedStore(useSettingsStore, { settings: buildSettings({ blur_booking_codes: true }) });
render(
);
- const block = screen.getByText(/blur booking codes/i).closest('div')!;
- const blurOnBtn = within(block).getByText(/^On$/i).closest('button')!;
- expect(blurOnBtn.style.border).toContain('var(--text-primary)');
+ expect(toggleFor(/blur booking codes/i)).toHaveAttribute('aria-pressed', 'true');
});
it('FE-COMP-DISPLAY-030: shows Always show booking routes next to Booking route labels', () => {
render(
);
- const bookingLabels = screen.getByText(/booking route labels/i);
- const alwaysShow = screen.getByText(/always show booking routes/i);
- expect(alwaysShow).toBeInTheDocument();
- // Adjacent siblings within the Travel & Map section: alwaysShow's block
- // immediately follows bookingLabels' block.
- expect(bookingLabels.closest('div')!.nextElementSibling).toBe(alwaysShow.closest('div'));
+ expect(screen.getByText(/always show booking routes/i)).toBeInTheDocument();
+ // Adjacent rows within the Travel & Map section: the always-show switch
+ // immediately follows the booking-labels switch.
+ const switches = Array.from(document.querySelectorAll('button[aria-label]'));
+ const labels = switches.indexOf(toggleFor(/booking route labels/i));
+ expect(switches.indexOf(toggleFor(/always show booking routes/i))).toBe(labels + 1);
});
- it('FE-COMP-DISPLAY-031: always-show-routes Off button is active by default (unset)', () => {
+ it('FE-COMP-DISPLAY-031: always-show-routes switch is off by default (unset)', () => {
render(
);
- const block = screen.getByText(/always show booking routes/i).closest('div')!;
- const offBtn = within(block).getByText(/^Off$/i).closest('button')!;
- expect(offBtn.style.border).toContain('var(--text-primary)');
+ expect(toggleFor(/always show booking routes/i)).toHaveAttribute('aria-pressed', 'false');
});
- it('FE-COMP-DISPLAY-032: clicking On for always-show-routes calls updateSetting with map_always_show_routes true', async () => {
+ it('FE-COMP-DISPLAY-032: switching always-show-routes on calls updateSetting with map_always_show_routes true', async () => {
const user = userEvent.setup();
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings(), updateSetting });
render(
);
- const block = screen.getByText(/always show booking routes/i).closest('div')!;
- await user.click(within(block).getByText(/^On$/i));
+ await user.click(toggleFor(/always show booking routes/i));
expect(updateSetting).toHaveBeenCalledWith('map_always_show_routes', true);
});
@@ -175,7 +172,7 @@ describe('DisplaySettingsTab', () => {
render(
);
await user.click(screen.getByText('°F Fahrenheit'));
const fahrenheitBtn = screen.getByText('°F Fahrenheit').closest('button')!;
- expect(fahrenheitBtn.style.border).toContain('var(--text-primary)');
+ expect(fahrenheitBtn).toHaveAttribute('aria-pressed', 'true');
});
});
@@ -187,9 +184,11 @@ function seedFailing(message = 'Server error') {
return updateSetting;
}
-/** The block of buttons belonging to one labelled setting. */
-function optionBlock(label: RegExp): HTMLElement {
- return screen.getByText(label).closest('div') as HTMLElement;
+/** The row of one labelled setting: climbs from its label until the row's control is inside. */
+function optionBlock(label: RegExp | string): HTMLElement {
+ let el: HTMLElement | null = screen.getByText(label);
+ while (el && !el.querySelector('button')) el = el.parentElement;
+ return el as HTMLElement;
}
describe('DisplaySettingsTab – Display currency', () => {
@@ -236,7 +235,7 @@ describe('DisplaySettingsTab – Display currency', () => {
// ── Compact language dropdown (035–039) ───────────────────────────────────────
function mobileLangWrap(): HTMLElement {
- return screen.getByText('Language').closest('div')!.querySelector('.sm\\:hidden') as HTMLElement;
+ return optionBlock('Language').querySelector('.sm\\:hidden') as HTMLElement;
}
describe('DisplaySettingsTab – Compact language picker', () => {
@@ -323,7 +322,7 @@ describe('DisplaySettingsTab – Map and privacy toggles', () => {
seedStore(useSettingsStore, { settings: buildSettings({ map_booking_labels: true }), updateSetting });
render(
);
- await user.click(within(optionBlock(/booking route labels/i)).getByText(/^Off$/));
+ await user.click(toggleFor(/booking route labels/i));
expect(updateSetting).toHaveBeenCalledWith('map_booking_labels', false);
});
@@ -333,7 +332,7 @@ describe('DisplaySettingsTab – Map and privacy toggles', () => {
seedFailing('Labels locked');
render(<>
>);
- await user.click(within(optionBlock(/booking route labels/i)).getByText(/^On$/));
+ await user.click(toggleFor(/booking route labels/i));
expect(await screen.findByText('Labels locked')).toBeInTheDocument();
});
@@ -343,7 +342,7 @@ describe('DisplaySettingsTab – Map and privacy toggles', () => {
seedFailing('Routes locked');
render(<>
>);
- await user.click(within(optionBlock(/always show booking routes/i)).getByText(/^On$/));
+ await user.click(toggleFor(/always show booking routes/i));
expect(await screen.findByText('Routes locked')).toBeInTheDocument();
});
@@ -353,10 +352,8 @@ describe('DisplaySettingsTab – Map and privacy toggles', () => {
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings(), updateSetting });
render(
);
- const block = optionBlock(/explore places on the map/i);
-
- expect(within(block).getByText(/^On$/).closest('button')!.style.border).toContain('var(--text-primary)');
- await user.click(within(block).getByText(/^Off$/));
+ expect(toggleFor(/explore places on the map/i)).toHaveAttribute('aria-pressed', 'true');
+ await user.click(toggleFor(/explore places on the map/i));
expect(updateSetting).toHaveBeenCalledWith('map_poi_pill_enabled', false);
});
@@ -366,7 +363,7 @@ describe('DisplaySettingsTab – Map and privacy toggles', () => {
seedFailing('POI locked');
render(<>
>);
- await user.click(within(optionBlock(/explore places on the map/i)).getByText(/^Off$/));
+ await user.click(toggleFor(/explore places on the map/i));
expect(await screen.findByText('POI locked')).toBeInTheDocument();
});
@@ -377,7 +374,7 @@ describe('DisplaySettingsTab – Map and privacy toggles', () => {
seedStore(useSettingsStore, { settings: buildSettings({ blur_booking_codes: false }), updateSetting });
render(
);
- await user.click(within(optionBlock(/blur booking codes/i)).getByText(/^On$/));
+ await user.click(toggleFor(/blur booking codes/i));
expect(updateSetting).toHaveBeenCalledWith('blur_booking_codes', true);
});
@@ -387,10 +384,8 @@ describe('DisplaySettingsTab – Map and privacy toggles', () => {
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings(), updateSetting });
render(
);
- const block = optionBlock(/optimize route from accommodation/i);
-
- expect(within(block).getByText(/^On$/).closest('button')!.style.border).toContain('var(--text-primary)');
- await user.click(within(block).getByText(/^Off$/));
+ expect(toggleFor(/optimize route from accommodation/i)).toHaveAttribute('aria-pressed', 'true');
+ await user.click(toggleFor(/optimize route from accommodation/i));
expect(updateSetting).toHaveBeenCalledWith('optimize_from_accommodation', false);
});
@@ -400,10 +395,10 @@ describe('DisplaySettingsTab – Map and privacy toggles', () => {
seedFailing('Nope');
render(<>
>);
- await user.click(within(optionBlock(/blur booking codes/i)).getByText(/^On$/));
+ await user.click(toggleFor(/blur booking codes/i));
await screen.findByText('Nope');
- await user.click(within(optionBlock(/optimize route from accommodation/i)).getByText(/^Off$/));
+ await user.click(toggleFor(/optimize route from accommodation/i));
await user.click(screen.getByText('mi Imperial'));
await user.click(screen.getByRole('button', { name: /24h/ }));
@@ -417,7 +412,7 @@ describe('DisplaySettingsTab – startup destination', () => {
render(
);
const block = optionBlock(/^Start page$/);
- expect(within(block).getByText('Dashboard').closest('button')!.style.border).toContain('var(--text-primary)');
+ expect(within(block).getByText('Dashboard').closest('button')).toHaveAttribute('aria-pressed', 'true');
// Nothing to pick a tab for while TREK opens on the dashboard.
expect(screen.queryByText('Start tab')).not.toBeInTheDocument();
});
diff --git a/client/src/components/Settings/DisplaySettingsTab.tsx b/client/src/components/Settings/DisplaySettingsTab.tsx
index 388fb650d..19647d9fc 100644
--- a/client/src/components/Settings/DisplaySettingsTab.tsx
+++ b/client/src/components/Settings/DisplaySettingsTab.tsx
@@ -1,18 +1,27 @@
import React, { useState, useEffect, useRef } from 'react'
-import { Languages, Map, ChevronDown, Check, Rocket } from 'lucide-react'
+import { Languages, Map, ChevronDown, Rocket } from 'lucide-react'
import { SUPPORTED_LANGUAGES, useTranslation } from '../../i18n'
import { useSettingsStore, DEFAULT_SETTINGS } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
import CustomSelect from '../shared/CustomSelect'
+import { Segmented } from '../shared/dialogParts'
+import { fs } from '../shared/DialogShell'
+import { POPOVER } from '../Packing/packingPopoverStyles'
+import { PopoverItem } from '../Packing/PackingPopover'
import { preferredNavAppOptions } from '../Planner/placeNavigation'
import { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants'
import Section from './Section'
+import ToggleSwitch from './ToggleSwitch'
+import { ChoiceChips, SettingRow, SettingRows } from './settingsKit'
import { TRIP_TAB_IDS, TRIP_TAB_LABEL_KEYS } from '../../constants/tripTabs'
import { DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB } from '../../utils/startDestination'
import type { DistanceUnit } from '../../types'
import { DEFAULT_WEEK_START } from '@trek/shared'
import { weekStartOptions } from '../../utils/calendarWeek'
+/** A select on the right of a row: wide enough for a currency or a weekday, never wider than the row. */
+const SELECT_BOX = 'w-[260px] max-w-full'
+
export default function DisplaySettingsTab(): React.ReactElement {
const { settings, updateSetting } = useSettingsStore()
const { t, locale } = useTranslation()
@@ -46,322 +55,269 @@ export default function DisplaySettingsTab(): React.ReactElement {
const startPage = settings.start_page === 'active_trip' ? 'active_trip' : DEFAULT_START_PAGE
const startTripTab = settings.start_trip_tab || DEFAULT_START_TRIP_TAB
+ const currentLanguage = SUPPORTED_LANGUAGES.find(o => o.value === settings.language) || SUPPORTED_LANGUAGES[0]
+
+ const pickLanguage = async (value: string) => {
+ try { await updateSetting('language', value) }
+ catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
+ }
return (
<>
- {/* Where opening TREK lands */}
-
-
{t('settings.startPage')}
-
- {([
- { value: 'dashboard', label: t('settings.startPageDashboard') },
- { value: 'active_trip', label: t('settings.startPageActiveTrip') },
- ] as const).map(opt => (
- {
- try { await updateSetting('start_page', opt.value) }
- catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
- }}
- style={{
- display: 'flex', alignItems: 'center', gap: 8,
- padding: '10px 20px', borderRadius: 10, cursor: 'pointer',
- fontFamily: 'inherit', fontSize: 'calc(14px * var(--fs-scale-body, 1))', fontWeight: 500,
- border: startPage === opt.value ? '2px solid var(--text-primary)' : '2px solid var(--border-primary)',
- background: startPage === opt.value ? 'var(--bg-hover)' : 'var(--bg-card)',
- color: 'var(--text-primary)',
- transition: 'all 0.15s',
- }}
- >
- {opt.label}
-
- ))}
-
-
{t('settings.startPageHint')}
-
-
- {/* Which planner tab the trip opens on — only meaningful for 'active_trip' */}
- {startPage === 'active_trip' && (
-
-
{t('settings.startTripTab')}
-
{
- try { await updateSetting('start_trip_tab', String(v)) }
- catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
- }}
- options={TRIP_TAB_IDS.map(id => ({ value: id, label: t(TRIP_TAB_LABEL_KEYS[id]) }))}
+
+ {/* Where opening TREK lands */}
+ {
+ try { await updateSetting('start_page', value) }
+ catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
+ }}
+ />
+ }
/>
- {t('settings.startTripTabHint')}
-
- )}
+
+ {/* Which planner tab the trip opens on — only meaningful for 'active_trip' */}
+ {startPage === 'active_trip' && (
+
+ {
+ try { await updateSetting('start_trip_tab', String(v)) }
+ catch (e: unknown) { toast.error(e instanceof Error ? e.message : t('common.error')) }
+ }}
+ options={TRIP_TAB_IDS.map(id => ({ value: id, label: t(TRIP_TAB_LABEL_KEYS[id]) }))}
+ />
+