feat(client): add the Startup section to General settings

Desktop and mobile both get it, in the style of the file they live in -
the On/Off button pairs on desktop, segments and a picker sheet on mobile.
The tab picker only appears once "Active trip" is chosen, since there is
nothing for it to mean otherwise.

Labels come from the same keys the planner's tab bar uses, so the picker
can never name a tab differently from the tab itself.
This commit is contained in:
Maurice
2026-08-08 21:03:57 +02:00
committed by Maurice
parent 44eff7177e
commit 6bf2e0708d
27 changed files with 379 additions and 4 deletions
@@ -1,4 +1,4 @@
// FE-COMP-DISPLAY-001 to FE-COMP-DISPLAY-048
// FE-COMP-DISPLAY-001 to FE-COMP-DISPLAY-052
import { render, screen, within, fireEvent } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
@@ -395,3 +395,52 @@ describe('DisplaySettingsTab – Map and privacy toggles', () => {
expect(screen.getAllByText('Nope').length).toBeGreaterThan(0);
});
});
describe('DisplaySettingsTab – startup destination', () => {
it('FE-COMP-DISPLAY-049: defaults to the dashboard and hides the tab picker', () => {
seedStore(useSettingsStore, { settings: buildSettings() });
render(<DisplaySettingsTab />);
const block = optionBlock(/^Start page$/);
expect(within(block).getByText('Dashboard').closest('button')!.style.border).toContain('var(--text-primary)');
// Nothing to pick a tab for while TREK opens on the dashboard.
expect(screen.queryByText('Start tab')).not.toBeInTheDocument();
});
it('FE-COMP-DISPLAY-050: choosing the active trip saves it and reveals the tab picker', async () => {
const user = userEvent.setup();
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings(), updateSetting });
render(<DisplaySettingsTab />);
await user.click(within(optionBlock(/^Start page$/)).getByText('Active trip'));
expect(updateSetting).toHaveBeenCalledWith('start_page', 'active_trip');
});
it('FE-COMP-DISPLAY-051: with the active trip chosen, the tab picker stores the planner tab id', async () => {
const user = userEvent.setup();
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, {
settings: buildSettings({ start_page: 'active_trip', start_trip_tab: 'plan' }),
updateSetting,
});
render(<DisplaySettingsTab />);
await user.click(screen.getByRole('button', { name: 'Plan' }));
await user.click(await screen.findByText('Costs'));
// The German legacy id, not the English label the user sees.
expect(updateSetting).toHaveBeenCalledWith('start_trip_tab', 'finanzplan');
});
it('FE-COMP-DISPLAY-052: a rejected start-page change surfaces the error', async () => {
const user = userEvent.setup();
seedFailing('Start locked');
render(<><ToastContainer /><DisplaySettingsTab /></>);
await user.click(within(optionBlock(/^Start page$/)).getByText('Active trip'));
await screen.findByText('Start locked');
});
});
@@ -1,11 +1,13 @@
import React, { useState, useEffect, useRef } from 'react'
import { Languages, Map, ChevronDown, Check } from 'lucide-react'
import { Languages, Map, ChevronDown, Check, 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 { SYMBOLS, currenciesWith } from '../Budget/BudgetPanel.constants'
import Section from './Section'
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'
export default function DisplaySettingsTab(): React.ReactElement {
@@ -34,8 +36,60 @@ export default function DisplaySettingsTab(): React.ReactElement {
setDistanceUnit(settings.distance_unit || DEFAULT_SETTINGS.distance_unit)
}, [settings.distance_unit])
const startPage = settings.start_page === 'active_trip' ? 'active_trip' : DEFAULT_START_PAGE
const startTripTab = settings.start_trip_tab || DEFAULT_START_TRIP_TAB
return (
<>
<Section title={t('settings.general.startup')} icon={Rocket}>
{/* Where opening TREK lands */}
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.startPage')}</label>
<div className="flex gap-3">
{([
{ value: 'dashboard', label: t('settings.startPageDashboard') },
{ value: 'active_trip', label: t('settings.startPageActiveTrip') },
] as const).map(opt => (
<button
key={opt.value}
onClick={async () => {
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}
</button>
))}
</div>
<p className="text-xs mt-1 text-content-faint">{t('settings.startPageHint')}</p>
</div>
{/* Which planner tab the trip opens on — only meaningful for 'active_trip' */}
{startPage === 'active_trip' && (
<div>
<label className="block text-sm font-medium mb-2 text-content-secondary">{t('settings.startTripTab')}</label>
<CustomSelect
value={startTripTab}
onChange={async v => {
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]) }))}
/>
<p className="text-xs text-content-faint mt-2">{t('settings.startTripTabHint')}</p>
</div>
)}
</Section>
<Section title={t('settings.general.languageRegion')} icon={Languages}>
{/* Display currency */}
<div>