feat(05-04): calendar settings page with source management and visibility toggle

- calendar-settings-panel.tsx: source list with color dots, type badges, visibility toggle (CAL-02), edit/delete actions, connection test auto-run, delete confirmation dialog
- calendar-source-form.tsx: add/edit form with name/type/URL/credentials/color; Exchange-mode select for exchange type; username/password hidden for ICS; client-side https-only validation (T-05-14)
- settings/dashboard/calendar/page.tsx: route page rendering CalendarSettingsPanel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 15:26:06 +02:00
parent f99ff9a498
commit 24de136b9b
3 changed files with 713 additions and 0 deletions
@@ -0,0 +1,389 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import {
fetchSources,
addSource,
updateSource,
deleteSource,
testSource,
} from '@/lib/calendar-api';
import type { CalendarSource, CreateSourcePayload } from '@/lib/calendar-api';
import { CalendarSourceForm } from './calendar-source-form';
/**
* Type badge label map.
*/
const TYPE_LABELS: Record<string, string> = {
caldav: 'CalDAV',
ics: 'ICS',
exchange: 'Exchange',
};
/**
* Calendar Settings Panel (D-08, D-09, D-11).
*
* Manages calendar sources: list with visibility toggle (CAL-02),
* add/edit forms, connection test, delete with confirmation dialog.
*/
export function CalendarSettingsPanel() {
const t = useTranslations('widgets');
const ts = useTranslations('settings');
const [sources, setSources] = useState<CalendarSource[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showAddForm, setShowAddForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [testResults, setTestResults] = useState<Record<string, 'success' | 'error' | 'testing'>>({});
// Load sources on mount
useEffect(() => {
fetchSources()
.then(setSources)
.catch(() => {
// Silent fail — show empty state
})
.finally(() => setIsLoading(false));
}, []);
// Toggle visibility (CAL-02)
const handleVisibilityToggle = useCallback(
async (source: CalendarSource) => {
const newIsVisible = !source.isVisible;
// Optimistic update
setSources((prev) =>
prev.map((s) =>
s.id === source.id ? { ...s, isVisible: newIsVisible } : s,
),
);
try {
await updateSource(source.id, { isVisible: newIsVisible });
} catch {
// Revert on error
setSources((prev) =>
prev.map((s) =>
s.id === source.id ? { ...s, isVisible: source.isVisible } : s,
),
);
}
},
[],
);
// Add new source
const handleAddSource = useCallback(async (payload: CreateSourcePayload) => {
setIsSaving(true);
try {
const created = await addSource(payload);
setSources((prev) => [...prev, created]);
setShowAddForm(false);
// Auto-test connection after save
setTestResults((prev) => ({ ...prev, [created.id]: 'testing' }));
try {
const result = await testSource(created.id);
setTestResults((prev) => ({
...prev,
[created.id]: result.success ? 'success' : 'error',
}));
} catch {
setTestResults((prev) => ({ ...prev, [created.id]: 'error' }));
}
} catch {
// Error handled silently
} finally {
setIsSaving(false);
}
}, []);
// Delete source
const handleDeleteSource = useCallback(async (id: string) => {
try {
await deleteSource(id);
setSources((prev) => prev.filter((s) => s.id !== id));
setDeletingId(null);
} catch {
// Error handled silently
}
}, []);
// Loading state
if (isLoading) {
return (
<div className="text-sm text-muted-foreground">Loading...</div>
);
}
return (
<div className="space-y-6">
{/* Source list */}
{sources.length === 0 && !showAddForm && (
<p className="text-sm text-muted-foreground">
{t('calendar.sourceEmpty')}
</p>
)}
{sources.length > 0 && (
<div className="space-y-2">
{sources.map((source) => (
<div
key={source.id}
className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3"
>
{/* Color dot */}
<span
className="h-3 w-3 shrink-0 rounded-full"
style={{ backgroundColor: source.color || '#808080' }}
aria-hidden="true"
/>
{/* Name */}
<span className="flex-1 text-sm font-medium text-foreground">
{source.name}
</span>
{/* Type badge */}
<span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground">
{TYPE_LABELS[source.type] || source.type}
</span>
{/* Connection status indicator */}
{source.lastSyncError ? (
<span
title={source.lastSyncError}
className="text-orange-500"
aria-label="Sync error"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
</span>
) : testResults[source.id] === 'success' ? (
<span className="text-green-500" aria-label="Connection OK">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
</span>
) : testResults[source.id] === 'error' ? (
<span className="text-orange-500" aria-label="Connection error">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="15" y1="9" x2="9" y2="15" />
<line x1="9" y1="9" x2="15" y2="15" />
</svg>
</span>
) : null}
{/* Visibility toggle (CAL-02) */}
<button
type="button"
role="switch"
aria-checked={source.isVisible}
aria-label={`Toggle visibility for ${source.name}`}
className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ${
source.isVisible ? 'bg-primary' : 'bg-muted'
}`}
onClick={() => handleVisibilityToggle(source)}
>
<span
className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow-sm ring-0 transition-transform duration-200 ${
source.isVisible ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
{/* Edit button */}
<button
type="button"
className="text-muted-foreground hover:text-foreground transition-colors"
onClick={() =>
setEditingId(editingId === source.id ? null : source.id)
}
aria-label={`Edit ${source.name}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
{/* Delete button */}
<button
type="button"
className="text-muted-foreground hover:text-destructive transition-colors"
onClick={() => setDeletingId(source.id)}
aria-label={`Delete ${source.name}`}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
</button>
</div>
))}
</div>
)}
{/* Edit form (below the source being edited) */}
{editingId && (
<div className="rounded-md border border-border bg-card p-4">
<h3 className="mb-3 text-sm font-semibold text-foreground">
Edit Source
</h3>
<CalendarSourceForm
initialValues={(() => {
const s = sources.find((src) => src.id === editingId);
return s
? {
id: s.id,
name: s.name,
type: s.type,
url: s.url,
username: s.username ?? '',
exchangeMode: s.exchangeMode ?? undefined,
color: s.color ?? undefined,
}
: undefined;
})()}
onSave={async (payload) => {
setIsSaving(true);
try {
const updated = await updateSource(editingId, payload);
setSources((prev) =>
prev.map((s) => (s.id === editingId ? updated : s)),
);
setEditingId(null);
} catch {
// Error handled silently
} finally {
setIsSaving(false);
}
}}
onCancel={() => setEditingId(null)}
isSaving={isSaving}
/>
</div>
)}
{/* Add source button */}
{!showAddForm && (
<button
type="button"
onClick={() => setShowAddForm(true)}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
{t('calendar.addSource')}
</button>
)}
{/* Add source form */}
{showAddForm && (
<div className="rounded-md border border-border bg-card p-4">
<h3 className="mb-3 text-sm font-semibold text-foreground">
{t('calendar.addSource')}
</h3>
<CalendarSourceForm
onSave={handleAddSource}
onCancel={() => setShowAddForm(false)}
isSaving={isSaving}
/>
</div>
)}
{/* Delete confirmation dialog (UI-SPEC destructive pattern) */}
{deletingId && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={(e) => {
if (e.target === e.currentTarget) setDeletingId(null);
}}
>
<div
className="mx-4 max-w-md rounded-lg border border-border bg-card p-6 shadow-xl"
role="alertdialog"
aria-modal="true"
aria-label="Confirm deletion"
>
<h3 className="mb-2 text-lg font-semibold text-foreground">
{ts('sourceDeleteCta')}
</h3>
<p className="mb-4 text-sm text-muted-foreground">
{ts('sourceDeleteConfirm')}
</p>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => setDeletingId(null)}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted"
>
{ts('cancel')}
</button>
<button
type="button"
onClick={() => handleDeleteSource(deletingId)}
className="rounded bg-destructive px-4 py-2 text-sm font-medium text-destructive-foreground transition-colors hover:bg-destructive/90"
>
{ts('sourceDeleteCta')}
</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,303 @@
'use client';
import { useCallback, useState } from 'react';
import { useTranslations } from 'next-intl';
import type { CreateSourcePayload } from '@/lib/calendar-api';
/**
* 8-color palette for calendar sources (UI-SPEC).
* OKLCH values mapped to hex for the color input.
*/
const SOURCE_COLOR_PALETTE = [
{ label: 'Red', value: '#c44040' },
{ label: 'Green', value: '#40a060' },
{ label: 'Blue', value: '#4060c4' },
{ label: 'Purple', value: '#8040c4' },
{ label: 'Orange', value: '#c49040' },
{ label: 'Teal', value: '#409090' },
{ label: 'Pink', value: '#c44080' },
{ label: 'Gray', value: '#808080' },
];
/**
* Source types available for selection.
*/
const SOURCE_TYPES = [
{ value: 'caldav', label: 'CalDAV' },
{ value: 'ics', label: 'ICS' },
{ value: 'exchange', label: 'Exchange' },
] as const;
/**
* Exchange mode options (RESEARCH open question 2).
*/
const EXCHANGE_MODES = [
{ value: 'graph', label: 'Exchange Online (Graph)' },
{ value: 'ews', label: 'Exchange Server (EWS)' },
] as const;
interface CalendarSourceFormProps {
onSave: (payload: CreateSourcePayload) => Promise<void>;
onCancel: () => void;
/** Initial values for edit mode */
initialValues?: Partial<CreateSourcePayload> & { id?: string };
isSaving?: boolean;
}
/**
* Calendar source form for adding or editing a calendar source (D-08, D-09).
*
* Fields: Name (required), Type (select, required), URL (required, https validation),
* Exchange mode (only for Exchange), Username (hidden for ICS), Password (hidden for ICS),
* Color (8-color palette).
*
* T-05-14: Client-side https-only validation before submit.
*/
export function CalendarSourceForm({
onSave,
onCancel,
initialValues,
isSaving = false,
}: CalendarSourceFormProps) {
const t = useTranslations('widgets');
const [name, setName] = useState(initialValues?.name ?? '');
const [type, setType] = useState<string>(initialValues?.type ?? '');
const [url, setUrl] = useState(initialValues?.url ?? '');
const [username, setUsername] = useState(initialValues?.username ?? '');
const [password, setPassword] = useState(initialValues?.password ?? '');
const [exchangeMode, setExchangeMode] = useState(
initialValues?.exchangeMode ?? 'graph',
);
const [color, setColor] = useState(
initialValues?.color ?? SOURCE_COLOR_PALETTE[0].value,
);
const [urlError, setUrlError] = useState('');
const isICS = type === 'ics';
const isExchange = type === 'exchange';
// Validate URL is https (T-05-14)
const validateUrl = useCallback((value: string): boolean => {
if (!value) return false;
try {
const parsed = new URL(value);
if (parsed.protocol !== 'https:') {
setUrlError('URL must use HTTPS protocol');
return false;
}
setUrlError('');
return true;
} catch {
setUrlError('Invalid URL format');
return false;
}
}, []);
// Form is valid when name, type, and url are filled and url is https
const isFormValid =
name.trim() !== '' &&
type !== '' &&
url.trim() !== '' &&
!urlError &&
validateUrl(url);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!validateUrl(url)) return;
const payload: CreateSourcePayload = {
name: name.trim(),
type: type as 'caldav' | 'ics' | 'exchange',
url: url.trim(),
color,
};
// Only include credentials for non-ICS types
if (!isICS) {
if (username.trim()) payload.username = username.trim();
if (password) payload.password = password;
}
// Include exchangeMode for Exchange type
if (isExchange) {
payload.exchangeMode = exchangeMode;
}
await onSave(payload);
},
[name, type, url, username, password, exchangeMode, color, isICS, isExchange, onSave, validateUrl],
);
return (
<form onSubmit={handleSubmit} className="space-y-4">
{/* Name */}
<div>
<label htmlFor="source-name" className="mb-1 block text-sm text-foreground">
Name *
</label>
<input
id="source-name"
type="text"
required
className="h-9 w-full max-w-md rounded border border-border bg-background px-3 text-sm text-foreground"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
{/* Type */}
<div>
<label htmlFor="source-type" className="mb-1 block text-sm text-foreground">
Type *
</label>
<select
id="source-type"
required
className="h-9 w-full max-w-md rounded border border-border bg-background px-3 text-sm text-foreground"
value={type}
onChange={(e) => setType(e.target.value)}
>
<option value="">--</option>
{SOURCE_TYPES.map((st) => (
<option key={st.value} value={st.value}>
{st.label}
</option>
))}
</select>
</div>
{/* Exchange Mode (only when type is exchange) */}
{isExchange && (
<div>
<label
htmlFor="exchange-mode"
className="mb-1 block text-sm text-foreground"
>
Exchange Mode
</label>
<select
id="exchange-mode"
className="h-9 w-full max-w-md rounded border border-border bg-background px-3 text-sm text-foreground"
value={exchangeMode}
onChange={(e) => setExchangeMode(e.target.value)}
>
{EXCHANGE_MODES.map((em) => (
<option key={em.value} value={em.value}>
{em.label}
</option>
))}
</select>
</div>
)}
{/* URL */}
<div>
<label htmlFor="source-url" className="mb-1 block text-sm text-foreground">
URL *
</label>
<input
id="source-url"
type="url"
required
placeholder="https://"
className={`h-9 w-full max-w-md rounded border px-3 text-sm text-foreground bg-background ${
urlError ? 'border-destructive' : 'border-border'
}`}
value={url}
onChange={(e) => {
setUrl(e.target.value);
if (e.target.value) validateUrl(e.target.value);
else setUrlError('');
}}
onBlur={() => {
if (url) validateUrl(url);
}}
/>
{urlError && (
<p className="mt-1 text-xs text-destructive">{urlError}</p>
)}
</div>
{/* Username (hidden for ICS) */}
{!isICS && (
<div>
<label
htmlFor="source-username"
className="mb-1 block text-sm text-foreground"
>
Username
</label>
<input
id="source-username"
type="text"
className="h-9 w-full max-w-md rounded border border-border bg-background px-3 text-sm text-foreground"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
)}
{/* Password (hidden for ICS) */}
{!isICS && (
<div>
<label
htmlFor="source-password"
className="mb-1 block text-sm text-foreground"
>
Password
</label>
<input
id="source-password"
type="password"
className="h-9 w-full max-w-md rounded border border-border bg-background px-3 text-sm text-foreground"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</div>
)}
{/* Color */}
<div>
<label className="mb-1 block text-sm text-foreground">Color</label>
<div className="flex gap-2">
{SOURCE_COLOR_PALETTE.map((c) => (
<button
key={c.value}
type="button"
title={c.label}
className={`h-7 w-7 rounded-full border-2 transition-all ${
color === c.value
? 'border-foreground scale-110'
: 'border-transparent hover:border-muted-foreground'
}`}
style={{ backgroundColor: c.value }}
onClick={() => setColor(c.value)}
/>
))}
</div>
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
<button
type="submit"
data-testid="source-form-submit"
disabled={!isFormValid || isSaving}
className="rounded bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSaving ? 'Saving...' : 'Save'}
</button>
<button
type="button"
onClick={onCancel}
className="rounded border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-muted"
>
{t('calendar.addSource') === 'Add source' ? 'Cancel' : 'Abbrechen'}
</button>
</div>
</form>
);
}