-
-
-
{trip?.title}
- {(trip?.start_date || trip?.end_date) && (
-
- {[trip.start_date, trip.end_date].filter(Boolean).map(d => new Date(d + 'T00:00:00Z').toLocaleDateString(locale, { day: 'numeric', month: 'short', timeZone: 'UTC' })).join(' – ')}
- {days.length > 0 && ` · ${days.length} ${t('dayplan.days')}`}
-
- )}
-
+ {/* Toolbar */}
+
+
)}
+ {(() => {
+ const allExpanded = days.length > 0 && days.every(d => expandedDays.has(d.id))
+ const label = allExpanded ? t('dayplan.collapseAll') : t('dayplan.expandAll')
+ return (
+
+
+
+ )
+ })()}
{onUndo && (
}
{canEditDays && onAddTransport && (
+
+
)}
{(() => {
const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
@@ -1217,15 +1257,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
- {canEditDays &&
}
toggleDay(day.id, e)}
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
diff --git a/client/src/components/Planner/PlacesSidebar.test.tsx b/client/src/components/Planner/PlacesSidebar.test.tsx
index 7d7221da..adb2881d 100644
--- a/client/src/components/Planner/PlacesSidebar.test.tsx
+++ b/client/src/components/Planner/PlacesSidebar.test.tsx
@@ -195,7 +195,7 @@ describe('Filter tabs', () => {
const assignments = { '1': [buildAssignment({ place: planned, day_id: 1 })] };
render();
await user.click(screen.getByRole('button', { name: /Unplanned/i }));
- await user.click(screen.getByRole('button', { name: /^All$/i }));
+ await user.click(screen.getByRole('button', { name: /^All/i }));
expect(screen.getByText('Planned Place')).toBeInTheDocument();
expect(screen.getByText('Unplanned Place')).toBeInTheDocument();
});
diff --git a/client/src/components/Planner/PlacesSidebar.tsx b/client/src/components/Planner/PlacesSidebar.tsx
index b7de7478..40f23a3a 100644
--- a/client/src/components/Planner/PlacesSidebar.tsx
+++ b/client/src/components/Planner/PlacesSidebar.tsx
@@ -13,6 +13,7 @@ import { useCanDo } from '../../store/permissionsStore'
import type { Place, Category, Day, AssignmentsMap } from '../../types'
import FileImportModal from './FileImportModal'
import ConfirmDialog from '../shared/ConfirmDialog'
+import Tooltip from '../shared/Tooltip'
interface PlacesSidebarProps {
tripId: number
@@ -372,74 +373,65 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
>
{t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')}
-
{ setSelectMode(v => !v); setSelectedIds(new Set()) }}
- style={{
- display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
- padding: '5px 10px', borderRadius: 8,
- border: `1px solid ${selectMode ? 'var(--accent)' : 'var(--border-primary)'}`,
- background: selectMode ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : 'none',
- color: selectMode ? 'var(--accent)' : 'var(--text-faint)', fontSize: 11, fontWeight: 500,
- cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
- }}
- >
- {t('common.select')}
-
- {selectMode && (
-
-
- {t('places.selectionCount', { count: selectedIds.size })}
-
- {
- if (selectedIds.size === filtered.length) {
- setSelectedIds(new Set())
- } else {
- setSelectedIds(new Set(filtered.map(p => p.id)))
- }
- }}
- style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: 11, fontFamily: 'inherit', padding: '2px 4px', borderRadius: 4 }}
- >
- {selectedIds.size === filtered.length && filtered.length > 0 ? t('common.deselectAll') : t('common.selectAll')}
-
- {
- if (selectedIds.size === 0) return
- if (isMobile) {
- setPendingDeleteIds(Array.from(selectedIds))
- } else {
- onBulkDeletePlaces?.(Array.from(selectedIds))
- }
- }}
- disabled={selectedIds.size === 0}
- style={{
- display: 'flex', alignItems: 'center', gap: 4, background: 'none', border: 'none',
- cursor: selectedIds.size > 0 ? 'pointer' : 'default',
- color: selectedIds.size > 0 ? '#ef4444' : 'var(--text-faint)',
- fontSize: 11, fontFamily: 'inherit', padding: '2px 4px', borderRadius: 4, fontWeight: 500,
- }}
- >
- {t('places.deleteSelected')}
-
-
-
-
-
- )}
>}
{/* Filter-Tabs */}
-
- {([{ id: 'all', label: t('places.all') }, { id: 'unplanned', label: t('places.unplanned') }, hasTracks ? { id: 'tracks', label: t('places.filterTracks') } : null] as const).filter(Boolean).map(f => (
- { setFilter(f.id); onPlacesFilterChange?.(f.id); setSelectedIds(new Set()) }} style={{
- padding: '4px 10px', borderRadius: 20, border: 'none', cursor: 'pointer',
- fontSize: 11, fontWeight: 500, fontFamily: 'inherit',
- background: filter === f.id ? 'var(--accent)' : 'var(--bg-tertiary)',
- color: filter === f.id ? 'var(--accent-text)' : 'var(--text-muted)',
- }}>{f.label}
- ))}
-
+ {(() => {
+ const baseFiltered = places.filter(p => {
+ if (categoryFilters.size > 0) {
+ if (p.category_id == null) {
+ if (!categoryFilters.has('uncategorized')) return false
+ } else if (!categoryFilters.has(String(p.category_id))) return false
+ }
+ if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
+ !(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
+ return true
+ })
+ const counts = {
+ all: baseFiltered.length,
+ unplanned: baseFiltered.filter(p => !plannedIds.has(p.id)).length,
+ tracks: baseFiltered.filter(p => p.route_geometry).length,
+ }
+ const tabs = ([
+ { id: 'all', label: t('places.all') },
+ { id: 'unplanned', label: t('places.unplanned') },
+ hasTracks ? { id: 'tracks', label: t('places.filterTracks') } : null,
+ ] as const).filter(Boolean) as Array<{ id: 'all' | 'unplanned' | 'tracks'; label: string }>
+ return (
+
+ {tabs.map(f => {
+ const active = filter === f.id
+ return (
+ { setFilter(f.id); onPlacesFilterChange?.(f.id); setSelectedIds(new Set()) }}
+ style={{
+ appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
+ display: 'inline-flex', alignItems: 'center', gap: 5,
+ padding: '4px 9px', borderRadius: 99,
+ fontSize: 11, fontWeight: 500, whiteSpace: 'nowrap',
+ background: active ? 'var(--accent)' : 'var(--bg-card)',
+ color: active ? 'var(--accent-text)' : 'var(--text-primary)',
+ boxShadow: active ? 'none' : '0 1px 2px rgba(0,0,0,0.06)',
+ transition: 'background 0.15s, color 0.15s, box-shadow 0.15s',
+ }}
+ >
+ {f.label}
+
+ {counts[f.id]}
+
+
+ )
+ })}
+
+ )
+ })()}
{/* Suchfeld */}
@@ -470,9 +462,9 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
? (categoryFilters.has('uncategorized') ? t('places.noCategory') : categories.find(c => categoryFilters.has(String(c.id)))?.name || t('places.allCategories'))
: `${categoryFilters.size} ${t('places.categoriesSelected')}`
return (
-
+
setCatDropOpen(v => !v)} style={{
- width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
+ flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)',
background: 'var(--bg-card)', fontSize: 12, color: 'var(--text-primary)',
cursor: 'pointer', fontFamily: 'inherit',
@@ -480,6 +472,41 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
{label}
+ {canEditPlaces && (
+
+ { setSelectMode(v => !v); setSelectedIds(new Set()) }}
+ aria-label={t('common.select')}
+ aria-pressed={selectMode}
+ style={{
+ position: 'relative', width: 30, flexShrink: 0, borderRadius: 8,
+ border: `1px solid ${selectMode ? 'var(--accent)' : 'var(--border-primary)'}`,
+ background: selectMode ? 'color-mix(in srgb, var(--accent) 14%, transparent)' : 'var(--bg-card)',
+ color: selectMode ? 'var(--accent)' : 'var(--text-faint)',
+ cursor: 'pointer', fontFamily: 'inherit', padding: 0,
+ transition: 'background 0.18s, color 0.18s, border-color 0.18s',
+ overflow: 'hidden',
+ }}
+ >
+
+
+
+
+
+
+
+
+ )}
{catDropOpen && (
- {/* Anzahl */}
-
- {filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}
-
+ {/* Anzahl / Auswahl-Leiste */}
+ {selectMode ? (
+
+
+ {t('places.selectionCount', { count: selectedIds.size })}
+
+ 0 ? t('common.deselectAll') : t('common.selectAll')} placement="bottom">
+ {
+ if (selectedIds.size === filtered.length) setSelectedIds(new Set())
+ else setSelectedIds(new Set(filtered.map(p => p.id)))
+ }}
+ aria-label={selectedIds.size === filtered.length && filtered.length > 0 ? t('common.deselectAll') : t('common.selectAll')}
+ style={{
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ width: 24, height: 24, borderRadius: 6, border: 'none',
+ background: 'transparent', color: 'var(--text-muted)', cursor: 'pointer', padding: 0,
+ }}
+ onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
+ onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
+ >
+
+
+
+
+ {
+ if (selectedIds.size === 0) return
+ if (isMobile) setPendingDeleteIds(Array.from(selectedIds))
+ else onBulkDeletePlaces?.(Array.from(selectedIds))
+ }}
+ disabled={selectedIds.size === 0}
+ aria-label={t('places.deleteSelected')}
+ style={{
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
+ width: 24, height: 24, borderRadius: 6, border: 'none',
+ background: 'transparent',
+ color: selectedIds.size > 0 ? '#ef4444' : 'var(--text-faint)',
+ cursor: selectedIds.size > 0 ? 'pointer' : 'default', padding: 0,
+ }}
+ onMouseEnter={e => { if (selectedIds.size > 0) e.currentTarget.style.background = 'color-mix(in srgb, #ef4444 14%, transparent)' }}
+ onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
+ >
+
+
+
+
+ ) : (
+
+ {filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}
+
+ )}
{/* Liste */}
diff --git a/client/src/components/Settings/DisplaySettingsTab.test.tsx b/client/src/components/Settings/DisplaySettingsTab.test.tsx
index bf2dd919..4c770e6c 100644
--- a/client/src/components/Settings/DisplaySettingsTab.test.tsx
+++ b/client/src/components/Settings/DisplaySettingsTab.test.tsx
@@ -42,7 +42,7 @@ describe('DisplaySettingsTab', () => {
it('FE-COMP-DISPLAY-005: shows Auto mode button', () => {
render(
);
- expect(screen.getByText('Auto')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Auto/i })).toBeInTheDocument();
});
it('FE-COMP-DISPLAY-006: shows Language section', () => {
@@ -95,16 +95,16 @@ describe('DisplaySettingsTab', () => {
const updateSetting = vi.fn().mockResolvedValue(undefined);
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'light' }), updateSetting });
render(
);
- await user.click(screen.getByText('Auto'));
+ await user.click(screen.getByRole('button', { name: /Auto/i }));
expect(updateSetting).toHaveBeenCalledWith('dark_mode', 'auto');
});
it('FE-COMP-DISPLAY-014: active color mode button has border with var(--text-primary)', () => {
seedStore(useSettingsStore, { settings: buildSettings({ dark_mode: 'dark' }) });
render(
);
- const darkBtn = screen.getByText('Dark').closest('button')!;
- const lightBtn = screen.getByText('Light').closest('button')!;
- const autoBtn = screen.getByText('Auto').closest('button')!;
+ const darkBtn = screen.getByRole('button', { name: /^Dark$/i });
+ const lightBtn = screen.getByRole('button', { name: /^Light$/i });
+ const autoBtn = screen.getByRole('button', { name: /Auto/i });
expect(darkBtn.style.border).toContain('var(--text-primary)');
expect(lightBtn.style.border).toContain('var(--border-primary)');
expect(autoBtn.style.border).toContain('var(--border-primary)');
@@ -122,8 +122,11 @@ describe('DisplaySettingsTab', () => {
it('FE-COMP-DISPLAY-016: active language button is visually highlighted', () => {
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }) });
render(
);
- const englishBtn = screen.getByText('English').closest('button')!;
- expect(englishBtn.style.border).toContain('var(--text-primary)');
+ // 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();
});
it('FE-COMP-DISPLAY-017: shows Temperature section label', () => {
diff --git a/client/src/components/Settings/DisplaySettingsTab.tsx b/client/src/components/Settings/DisplaySettingsTab.tsx
index e84f00e1..0eaec1ca 100644
--- a/client/src/components/Settings/DisplaySettingsTab.tsx
+++ b/client/src/components/Settings/DisplaySettingsTab.tsx
@@ -1,5 +1,5 @@
-import React, { useState, useEffect } from 'react'
-import { Palette, Sun, Moon, Monitor } from 'lucide-react'
+import React, { useState, useEffect, useRef } from 'react'
+import { Palette, Sun, Moon, Monitor, ChevronDown, Check } from 'lucide-react'
import { SUPPORTED_LANGUAGES, useTranslation } from '../../i18n'
import { useSettingsStore } from '../../store/settingsStore'
import { useToast } from '../shared/Toast'
@@ -10,6 +10,17 @@ export default function DisplaySettingsTab(): React.ReactElement {
const { t } = useTranslation()
const toast = useToast()
const [tempUnit, setTempUnit] = useState
(settings.temperature_unit || 'celsius')
+ const [langOpen, setLangOpen] = useState(false)
+ const langDropdownRef = useRef(null)
+
+ useEffect(() => {
+ if (!langOpen) return
+ const handler = (e: MouseEvent) => {
+ if (langDropdownRef.current && !langDropdownRef.current.contains(e.target as Node)) setLangOpen(false)
+ }
+ document.addEventListener('mousedown', handler)
+ return () => document.removeEventListener('mousedown', handler)
+ }, [langOpen])
useEffect(() => {
setTempUnit(settings.temperature_unit || 'celsius')
@@ -46,8 +57,13 @@ export default function DisplaySettingsTab(): React.ReactElement {
transition: 'all 0.15s',
}}
>
-
- {opt.label}
+
+ {opt.value === 'auto' ? (
+ <>
+ {opt.label}
+ Auto
+ >
+ ) : opt.label}
)
})}
@@ -57,7 +73,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
{/* Language */}
-
+ {/* Desktop: Button grid */}
+
{SUPPORTED_LANGUAGES.map(opt => (
))}
+ {/* Mobile: Custom dropdown */}
+
+ {(() => {
+ const current = SUPPORTED_LANGUAGES.find(o => o.value === settings.language) || SUPPORTED_LANGUAGES[0]
+ return (
+
setLangOpen(v => !v)}
+ style={{
+ width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
+ padding: '10px 14px', borderRadius: 10,
+ border: '2px solid var(--border-primary)',
+ background: 'var(--bg-card)', color: 'var(--text-primary)',
+ fontSize: 14, fontWeight: 500, fontFamily: 'inherit', cursor: 'pointer',
+ }}
+ >
+ {current?.label}
+
+
+ )
+ })()}
+ {langOpen && (
+
+ {SUPPORTED_LANGUAGES.map(opt => {
+ const active = settings.language === opt.value
+ return (
+ {
+ setLangOpen(false)
+ try { await updateSetting('language', opt.value) }
+ catch (err: unknown) { toast.error(err instanceof Error ? err.message : t('common.error')) }
+ }}
+ style={{
+ display: 'flex', alignItems: 'center', gap: 8, width: '100%',
+ padding: '9px 12px', borderRadius: 6, border: 'none', cursor: 'pointer',
+ background: active ? 'var(--bg-hover)' : 'transparent',
+ fontFamily: 'inherit', fontSize: 14, color: 'var(--text-primary)',
+ textAlign: 'left', fontWeight: active ? 600 : 500,
+ }}
+ >
+ {opt.label}
+ {active && }
+
+ )
+ })}
+
+ )}
+
{/* Temperature */}
diff --git a/client/src/components/shared/Tooltip.tsx b/client/src/components/shared/Tooltip.tsx
new file mode 100644
index 00000000..bb9653f3
--- /dev/null
+++ b/client/src/components/shared/Tooltip.tsx
@@ -0,0 +1,98 @@
+import React, { useState, useRef, useEffect } from 'react'
+import ReactDOM from 'react-dom'
+
+type Placement = 'top' | 'bottom' | 'left' | 'right'
+
+interface TooltipProps {
+ label: string
+ placement?: Placement
+ delay?: number
+ disabled?: boolean
+ children: React.ReactElement
+}
+
+export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, children }: TooltipProps) {
+ const [open, setOpen] = useState(false)
+ const [coords, setCoords] = useState<{ top: number; left: number } | null>(null)
+ const triggerRef = useRef
(null)
+ const tooltipRef = useRef(null)
+ const timerRef = useRef(null)
+
+ const show = () => {
+ if (disabled || !label) return
+ if (timerRef.current) window.clearTimeout(timerRef.current)
+ timerRef.current = window.setTimeout(() => setOpen(true), delay)
+ }
+ const hide = () => {
+ if (timerRef.current) window.clearTimeout(timerRef.current)
+ setOpen(false)
+ }
+
+ useEffect(() => () => { if (timerRef.current) window.clearTimeout(timerRef.current) }, [])
+
+ useEffect(() => {
+ if (!open || !triggerRef.current) return
+ const r = triggerRef.current.getBoundingClientRect()
+ const tipW = tooltipRef.current?.offsetWidth ?? 0
+ const tipH = tooltipRef.current?.offsetHeight ?? 0
+ const gap = 6
+ let top = 0, left = 0
+ if (placement === 'top') { top = r.top - tipH - gap; left = r.left + r.width / 2 - tipW / 2 }
+ else if (placement === 'bottom') { top = r.bottom + gap; left = r.left + r.width / 2 - tipW / 2 }
+ else if (placement === 'left') { top = r.top + r.height / 2 - tipH / 2; left = r.left - tipW - gap }
+ else { top = r.top + r.height / 2 - tipH / 2; left = r.right + gap }
+ const pad = 6
+ left = Math.max(pad, Math.min(left, window.innerWidth - tipW - pad))
+ top = Math.max(pad, Math.min(top, window.innerHeight - tipH - pad))
+ setCoords({ top, left })
+ }, [open, placement, label])
+
+ const child = React.Children.only(children)
+ const trigger = React.cloneElement(child, {
+ ref: (node: HTMLElement | null) => {
+ triggerRef.current = node
+ const r = (child as any).ref
+ if (typeof r === 'function') r(node)
+ else if (r && typeof r === 'object') r.current = node
+ },
+ onMouseEnter: (e: any) => { show(); child.props.onMouseEnter?.(e) },
+ onMouseLeave: (e: any) => { hide(); child.props.onMouseLeave?.(e) },
+ onFocus: (e: any) => { show(); child.props.onFocus?.(e) },
+ onBlur: (e: any) => { hide(); child.props.onBlur?.(e) },
+ })
+
+ return (
+ <>
+ {trigger}
+ {open && ReactDOM.createPortal(
+
+ {label}
+
,
+ document.body,
+ )}
+ >
+ )
+}
+
+export default Tooltip
diff --git a/client/src/i18n/translations/de.ts b/client/src/i18n/translations/de.ts
index 9ae9a435..3ff2dfaa 100644
--- a/client/src/i18n/translations/de.ts
+++ b/client/src/i18n/translations/de.ts
@@ -148,7 +148,7 @@ const de: Record = {
'settings.subtitle': 'Konfigurieren Sie Ihre persönlichen Einstellungen',
'settings.tabs.display': 'Anzeige',
'settings.tabs.map': 'Karte',
- 'settings.tabs.notifications': 'Benachrichtigungen',
+ 'settings.tabs.notifications': 'Mitteilungen',
'settings.tabs.integrations': 'Integrationen',
'settings.tabs.account': 'Konto',
'settings.tabs.offline': 'Offline',
@@ -182,7 +182,7 @@ const de: Record = {
'settings.bookingLabels': 'Orts-Labels auf Buchungsrouten',
'settings.bookingLabelsHint': 'Zeigt Bahnhofs-/Flughafennamen auf der Karte. Wenn aus, wird nur das Icon angezeigt.',
'settings.blurBookingCodes': 'Buchungscodes verbergen',
- 'settings.notifications': 'Benachrichtigungen',
+ 'settings.notifications': 'Mitteilungen',
'settings.notifyTripInvite': 'Trip-Einladungen',
'settings.notifyBookingChange': 'Buchungsänderungen',
'settings.notifyTripReminder': 'Trip-Erinnerungen',
@@ -873,7 +873,7 @@ const de: Record = {
// Trip Planner
'trip.tabs.plan': 'Karte',
- 'trip.tabs.transports': 'Transporte',
+ 'trip.tabs.transports': 'Transport',
'trip.tabs.reservations': 'Buchungen',
'trip.tabs.reservationsShort': 'Buchung',
'trip.tabs.packing': 'Liste',
@@ -908,6 +908,8 @@ const de: Record = {
'dayplan.cannotDropOnTimed': 'Orte können nicht zwischen zeitgebundene Einträge geschoben werden',
'dayplan.cannotBreakChronology': 'Die zeitliche Reihenfolge von Uhrzeiten und Buchungen darf nicht verletzt werden',
'dayplan.addNote': 'Notiz hinzufügen',
+ 'dayplan.expandAll': 'Alle Tage ausklappen',
+ 'dayplan.collapseAll': 'Alle Tage einklappen',
'dayplan.editNote': 'Notiz bearbeiten',
'dayplan.noteAdd': 'Notiz hinzufügen',
'dayplan.noteEdit': 'Notiz bearbeiten',
@@ -932,7 +934,7 @@ const de: Record = {
// Places Sidebar
'places.addPlace': 'Ort/Aktivität hinzufügen',
- 'places.importFile': 'Datei importieren',
+ 'places.importFile': 'Dateimport',
'places.sidebarDrop': 'Ablegen zum Importieren',
'places.importFileHint': '.gpx-, .kml- oder .kmz-Dateien aus Tools wie Google My Maps, Google Earth oder einem GPS-Tracker importieren.',
'places.importFileDropHere': 'Datei auswählen oder hierher ziehen und ablegen',
diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts
index 9f12c2a7..5e2e593e 100644
--- a/client/src/i18n/translations/en.ts
+++ b/client/src/i18n/translations/en.ts
@@ -965,6 +965,8 @@ const en: Record = {
'dayplan.cannotDropOnTimed': 'Items cannot be placed between time-bound entries',
'dayplan.cannotBreakChronology': 'This would break the chronological order of timed items and bookings',
'dayplan.addNote': 'Add Note',
+ 'dayplan.expandAll': 'Expand all days',
+ 'dayplan.collapseAll': 'Collapse all days',
'dayplan.editNote': 'Edit Note',
'dayplan.noteAdd': 'Add Note',
'dayplan.noteEdit': 'Edit Note',
diff --git a/client/src/pages/AdminPage.tsx b/client/src/pages/AdminPage.tsx
index 9b976734..31f99000 100644
--- a/client/src/pages/AdminPage.tsx
+++ b/client/src/pages/AdminPage.tsx
@@ -903,7 +903,7 @@ export default function AdminPage(): React.ReactElement {
handleToggleAuthSetting('oidc_registration', !oidcRegistration, setOidcRegistration)}
- className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
+ className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors"
style={{ background: oidcRegistration ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
handleToggleRequireMfa(!requireMfa)}
- className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
+ className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors"
style={{ background: requireMfa ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
{/* Place Photos Toggle */}
-
+
{t('admin.placesPhotos.title')}
{t('admin.placesPhotos.subtitle')}
@@ -1048,7 +1048,7 @@ export default function AdminPage(): React.ReactElement {
setPlacesPhotosEnabled(next)
try { await adminApi.updatePlacesPhotos(next) } catch { setPlacesPhotosEnabledState(!next); setPlacesPhotosEnabled(!next) }
}}
- className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
+ className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors"
style={{ background: placesPhotosEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
@@ -1056,7 +1056,7 @@ export default function AdminPage(): React.ReactElement {
{/* Place Autocomplete Toggle */}
-
+
{t('admin.placesAutocomplete.title')}
{t('admin.placesAutocomplete.subtitle')}
@@ -1068,7 +1068,7 @@ export default function AdminPage(): React.ReactElement {
setPlacesAutocompleteEnabled(next)
try { await adminApi.updatePlacesAutocomplete(next) } catch { setPlacesAutocompleteEnabledState(!next); setPlacesAutocompleteEnabled(!next) }
}}
- className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
+ className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors"
style={{ background: placesAutocompleteEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
@@ -1076,7 +1076,7 @@ export default function AdminPage(): React.ReactElement {
{/* Place Details Toggle */}
-
+
{t('admin.placesDetails.title')}
{t('admin.placesDetails.subtitle')}
@@ -1088,7 +1088,7 @@ export default function AdminPage(): React.ReactElement {
setPlacesDetailsEnabled(next)
try { await adminApi.updatePlacesDetails(next) } catch { setPlacesDetailsEnabledState(!next); setPlacesDetailsEnabled(!next) }
}}
- className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
+ className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors"
style={{ background: placesDetailsEnabled ? 'var(--text-primary)' : 'var(--border-primary)' }}
>
@@ -1328,7 +1328,7 @@ export default function AdminPage(): React.ReactElement {
const newVal = smtpValues.smtp_skip_tls_verify === 'true' ? 'false' : 'true'
setSmtpValues(prev => ({ ...prev, smtp_skip_tls_verify: newVal }))
}}
- className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
+ className="relative inline-flex h-6 w-11 flex-shrink-0 items-center rounded-full transition-colors"
style={{ background: smtpValues.smtp_skip_tls_verify === 'true' ? 'var(--text-primary)' : 'var(--border-primary)' }}>
diff --git a/server/src/services/weatherService.ts b/server/src/services/weatherService.ts
index a7f2fde0..75ea5d5e 100644
--- a/server/src/services/weatherService.ts
+++ b/server/src/services/weatherService.ts
@@ -194,6 +194,37 @@ async function _getWeatherImpl(
}
}
+ // Past date: use archive API for the actual date
+ if (diffDays < -1) {
+ const dateStr = targetDate.toISOString().slice(0, 10);
+ const url = `https://archive-api.open-meteo.com/v1/archive?latitude=${lat}&longitude=${lng}&start_date=${dateStr}&end_date=${dateStr}&daily=temperature_2m_max,temperature_2m_min,weathercode,precipitation_sum&timezone=auto`;
+ const response = await fetch(url);
+ const data = await response.json() as OpenMeteoForecast;
+
+ if (!response.ok || data.error) {
+ throw new ApiError(response.status || 500, data.reason || 'Open-Meteo Archive API error');
+ }
+
+ const daily = data.daily;
+ if (daily && daily.time && daily.time.length > 0 && daily.temperature_2m_max[0] != null) {
+ const code = daily.weathercode?.[0];
+ const descriptions = lang === 'de' ? WMO_DESCRIPTION_DE : WMO_DESCRIPTION_EN;
+ const tMax = daily.temperature_2m_max[0];
+ const tMin = daily.temperature_2m_min[0];
+ const result: WeatherResult = {
+ temp: Math.round((tMax + tMin) / 2),
+ temp_max: Math.round(tMax),
+ temp_min: Math.round(tMin),
+ main: WMO_MAP[code!] || estimateCondition((tMax + tMin) / 2, daily.precipitation_sum?.[0] || 0),
+ description: descriptions[code!] || '',
+ type: 'forecast',
+ };
+ setCache(ck, result, TTL_CLIMATE_MS);
+ return result;
+ }
+ return { temp: 0, main: '', description: '', type: '', error: 'no_forecast' };
+ }
+
// Climate / archive fallback (far-future dates)
if (diffDays > -1) {
const month = targetDate.getMonth() + 1;
diff --git a/server/tests/unit/services/weatherService.test.ts b/server/tests/unit/services/weatherService.test.ts
index bf4258d2..474b9e40 100644
--- a/server/tests/unit/services/weatherService.test.ts
+++ b/server/tests/unit/services/weatherService.test.ts
@@ -282,13 +282,36 @@ describe('getWeather', () => {
});
describe('with date — past date (diffDays < -1)', () => {
- it('returns no_forecast error immediately without fetching', async () => {
+ it('returns forecast-type WeatherResult from the archive API', async () => {
const date = dateOffset(-5); // 5 days in the past
+ const archiveBody = {
+ daily: {
+ time: [date],
+ temperature_2m_max: [18],
+ temperature_2m_min: [10],
+ weathercode: [2],
+ precipitation_sum: [0],
+ },
+ };
+ vi.mocked(fetch).mockResolvedValueOnce(mockResponse(archiveBody));
const result = await getWeather('14.00', '24.00', date, 'en');
+ expect(result.type).toBe('forecast');
+ expect(result.temp).toBe(14);
+ expect(result.temp_max).toBe(18);
+ expect(result.temp_min).toBe(10);
+ expect(fetch).toHaveBeenCalledTimes(1);
+ expect(vi.mocked(fetch).mock.calls[0][0]).toContain('archive-api.open-meteo.com');
+ });
+
+ it('returns no_forecast error when archive has no data for the date', async () => {
+ const date = dateOffset(-5);
+ vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ daily: { time: [], temperature_2m_max: [], temperature_2m_min: [], weathercode: [] } }));
+
+ const result = await getWeather('14.01', '24.01', date, 'en');
+
expect(result.error).toBe('no_forecast');
- expect(fetch).not.toHaveBeenCalled();
});
});