feat(web): CalendarConfig im Einstellungsfeld, Changelog- und Handbuch-Eintrag

- Kalender-Zweig im Einstellungsfeld ersetzt den englischen Fliesstext durch CalendarConfig (Monatsansicht ein/aus, Anzahl Termine 0..10, Zeitraum 7/14/30/60/90) nach Muster ClockConfig; Speichern per partiellem updateWidgetConfig
- Paneltest um drei Kalender-Tests erweitert (7/7 gruen), next-intl-Mock interpoliert jetzt {platzhalter}
- CHANGELOG.md: zweiter Eintrag unter Unveroeffentlicht/Geaendert; Anwenderhandbuch: Widget-Tabellenzeile und Dashboard-Widgets-Absatz ergaenzt
- Gesamtlauf: 51 Testdateien / 332 Tests gruen, Type-Check Exit 0 (quick-260916-htc)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-16 13:12:48 +02:00
parent 61996dceef
commit 6d8c7c46a8
4 changed files with 177 additions and 16 deletions
@@ -19,7 +19,12 @@ vi.mock('next-intl', async () => {
| string
| undefined;
return {
useTranslations: (ns?: string) => (key: string) => lookup(ns ? `${ns}.${key}` : key) ?? key,
useTranslations:
(ns?: string) =>
(key: string, values?: Record<string, unknown>) => {
const raw = lookup(ns ? `${ns}.${key}` : key) ?? key;
return values ? raw.replace(/\{(\w+)\}/g, (_: string, n: string) => String(values[n] ?? '')) : raw;
},
};
});
@@ -36,6 +41,7 @@ vi.mock('@/components/settings/search-provider-form', () => ({
}));
const clockTexts = (de as { widgets: { clock: Record<string, string> } }).widgets.clock;
const cal = (de as { widgets: { calendar: Record<string, string> } }).widgets.calendar;
const WIDGET = { id: 'c1', widgetType: 'clock', config: { timezone: 'Europe/Berlin', timeFontSizePt: 24 } };
@@ -105,3 +111,74 @@ describe('WidgetSettingsPanel — Schriftgroesse der Uhrzeit (quick-260916-bwo)'
expect(screen.getByRole('alert')).toBeInTheDocument();
});
});
describe('WidgetSettingsPanel — Kalender-Einstellungen (quick-260916-htc)', () => {
function renderCalendarExpanded(config: Record<string, unknown> = {}) {
const onWidgetUpdate = vi.fn();
const widget = { id: 'k1', widgetType: 'calendar', config };
render(<WidgetSettingsPanel widgets={[widget]} onWidgetUpdate={onWidgetUpdate} />);
fireEvent.click(screen.getByRole('button', { name: /Kalender #1/ }));
return { onWidgetUpdate };
}
it('Test 5: Vorbelegung aus der Konfiguration, Optionslisten und uebersetzte Link-Zeile', () => {
renderCalendarExpanded();
const showMonth = screen.getByLabelText(cal.configShowMonth) as HTMLInputElement;
expect(showMonth.checked).toBe(true);
const maxEventsSelect = screen.getByLabelText(cal.configMaxEvents) as HTMLSelectElement;
expect(maxEventsSelect.value).toBe('3');
const maxEventsOptionTexts = Array.from(maxEventsSelect.options).map((o) => o.textContent);
expect(maxEventsOptionTexts).toEqual([
cal.configMaxEventsNone,
cal.configMaxEventsOne,
...Array.from({ length: 9 }, (_, i) => cal.configMaxEventsMany.replace('{count}', String(i + 2))),
]);
const lookaheadSelect = screen.getByLabelText(cal.configLookahead) as HTMLSelectElement;
expect(lookaheadSelect.value).toBe('30');
const lookaheadOptionTexts = Array.from(lookaheadSelect.options).map((o) => o.textContent);
expect(lookaheadOptionTexts).toEqual(
[7, 14, 30, 60, 90].map((days) => cal.configLookaheadOption.replace('{days}', String(days))),
);
expect(screen.getByText(cal.configSourcesHint)).toBeInTheDocument();
const link = screen.getByRole('link', { name: cal.configSourcesLink });
expect(link).toHaveAttribute('href', '/settings/dashboard/calendar');
expect(screen.queryByText(/managed under/)).not.toBeInTheDocument();
});
it('Test 6: jede Aenderung ruft updateWidgetConfig/onWidgetUpdate nur mit dem geaenderten Feld auf', async () => {
const { onWidgetUpdate } = renderCalendarExpanded();
fireEvent.change(screen.getByLabelText(cal.configMaxEvents), { target: { value: '5' } });
await vi.waitFor(() => expect(onWidgetUpdate).toHaveBeenCalledWith('k1', { maxEvents: 5 }));
expect(updateWidgetConfig).toHaveBeenCalledWith('k1', { maxEvents: 5 });
fireEvent.change(screen.getByLabelText(cal.configLookahead), { target: { value: '14' } });
await vi.waitFor(() => expect(onWidgetUpdate).toHaveBeenCalledWith('k1', { lookaheadDays: 14 }));
expect(updateWidgetConfig).toHaveBeenCalledWith('k1', { lookaheadDays: 14 });
fireEvent.click(screen.getByLabelText(cal.configShowMonth));
await vi.waitFor(() => expect(onWidgetUpdate).toHaveBeenCalledWith('k1', { showMonth: false }));
expect(updateWidgetConfig).toHaveBeenCalledWith('k1', { showMonth: false });
expect(updateWidgetConfig).toHaveBeenCalledTimes(3);
});
it('Test 7: bestehende Werte werden angezeigt, ungueltige Werte geklemmt', () => {
renderCalendarExpanded({ showMonth: false, maxEvents: 7, lookaheadDays: 60 });
expect((screen.getByLabelText(cal.configShowMonth) as HTMLInputElement).checked).toBe(false);
expect((screen.getByLabelText(cal.configMaxEvents) as HTMLSelectElement).value).toBe('7');
expect((screen.getByLabelText(cal.configLookahead) as HTMLSelectElement).value).toBe('60');
cleanup();
renderCalendarExpanded({ maxEvents: 42, lookaheadDays: 45 });
expect((screen.getByLabelText(cal.configMaxEvents) as HTMLSelectElement).value).toBe('10');
expect((screen.getByLabelText(cal.configLookahead) as HTMLSelectElement).value).toBe('30');
});
});
@@ -11,6 +11,11 @@ import {
CLOCK_FONT_SIZE_MIN_PT,
resolveClockTimeFontSizePt,
} from '@/components/dashboard/widgets/clock-font-size';
import {
CALENDAR_LOOKAHEAD_OPTIONS,
CALENDAR_MAX_EVENTS_LIMIT,
resolveCalendarConfig,
} from '@/components/dashboard/widgets/calendar-month';
/**
* IANA timezones offered for clock configuration (D-12).
@@ -170,20 +175,14 @@ export function WidgetSettingsPanel({
/>
)}
{/* Calendar config — managed elsewhere */}
{/* Calendar config (quick-260916-htc) */}
{widget.widgetType === 'calendar' && (
<div className="text-sm text-muted-foreground">
<p>
Calendar sources are managed under{' '}
<Link
href="/settings/dashboard/calendar"
className="text-primary underline hover:text-primary/90"
>
Settings &gt; Dashboard &gt; Calendar
</Link>
.
</p>
</div>
<CalendarConfig
config={widget.config}
onChange={(cfg) =>
handleConfigChange(widget.id, cfg)
}
/>
)}
</div>
)}
@@ -317,6 +316,90 @@ function ClockConfig({
);
}
function CalendarConfig({
config,
onChange,
}: {
config: Record<string, unknown>;
onChange: (cfg: Record<string, unknown>) => void;
}) {
const t = useTranslations('widgets');
const { showMonth, maxEvents, lookaheadDays } = resolveCalendarConfig(config);
const maxEventsOptions = Array.from({ length: CALENDAR_MAX_EVENTS_LIMIT + 1 }, (_, n) => n);
return (
<div className="space-y-4">
{/* Monatsansicht ein-/ausblenden */}
<div className="flex items-center gap-3">
<input
id="calendar-show-month"
type="checkbox"
className="h-4 w-4 rounded border-border text-primary"
checked={showMonth}
onChange={(e) => onChange({ showMonth: e.target.checked })}
/>
<label htmlFor="calendar-show-month" className="text-sm text-foreground">
{t('calendar.configShowMonth')}
</label>
</div>
{/* Anzahl der angezeigten Termine */}
<div>
<label htmlFor="calendar-max-events" className="mb-1 block text-sm text-foreground">
{t('calendar.configMaxEvents')}
</label>
<select
id="calendar-max-events"
className="h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground"
value={String(maxEvents)}
onChange={(e) => onChange({ maxEvents: Number(e.target.value) })}
>
{maxEventsOptions.map((n) => (
<option key={n} value={String(n)}>
{n === 0
? t('calendar.configMaxEventsNone')
: n === 1
? t('calendar.configMaxEventsOne')
: t('calendar.configMaxEventsMany', { count: n })}
</option>
))}
</select>
</div>
{/* Zeitraum der angezeigten Termine */}
<div>
<label htmlFor="calendar-lookahead" className="mb-1 block text-sm text-foreground">
{t('calendar.configLookahead')}
</label>
<select
id="calendar-lookahead"
className="h-9 w-full max-w-xs rounded border border-border bg-background px-3 text-sm text-foreground"
value={String(lookaheadDays)}
onChange={(e) => onChange({ lookaheadDays: Number(e.target.value) })}
>
{CALENDAR_LOOKAHEAD_OPTIONS.map((days) => (
<option key={days} value={String(days)}>
{t('calendar.configLookaheadOption', { days })}
</option>
))}
</select>
</div>
{/* Hinweis auf die Kalenderquellenverwaltung */}
<p className="text-xs text-muted-foreground">
{t('calendar.configSourcesHint')}{' '}
<Link
href="/settings/dashboard/calendar"
className="text-primary underline hover:text-primary/90"
>
{t('calendar.configSourcesLink')}
</Link>
</p>
</div>
);
}
function NoteConfig({
config,
onChange,