UMBRA/frontend/src/components/calendar/EventDetailPanel.tsx
Kyle Pope 6cd648f3a8 Replace native date/time inputs with DatePicker across calendar and todo forms
- EventForm + EventDetailPanel: native <Input type=date|datetime-local> → DatePicker with dynamic mode via all_day toggle
- TodoForm + TodoDetailPanel: merge date + time into single datetime DatePicker, remove separate time input, move recurrence select into 2-col grid beside date picker

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:43:14 +08:00

919 lines
33 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { format, parseISO } from 'date-fns';
import {
X, Pencil, Trash2, Save, Clock, MapPin, AlignLeft, Repeat, Star, Calendar,
} from 'lucide-react';
import api, { getErrorMessage } from '@/lib/api';
import type { CalendarEvent, Location as LocationType, RecurrenceRule } from '@/types';
import { useCalendars } from '@/hooks/useCalendars';
import { useConfirmAction } from '@/hooks/useConfirmAction';
import { formatUpdatedAt } from '@/components/shared/utils';
import CopyableField from '@/components/shared/CopyableField';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Textarea } from '@/components/ui/textarea';
import { Select } from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import LocationPicker from '@/components/ui/location-picker';
// --- Helpers ---
function toDateOnly(dt: string): string {
if (!dt) return '';
return dt.split('T')[0];
}
function toDatetimeLocal(dt: string, fallbackTime = '09:00'): string {
if (!dt) return '';
if (dt.includes('T')) return dt.slice(0, 16);
return `${dt}T${fallbackTime}`;
}
function formatForInput(dt: string, allDay: boolean, fallbackTime = '09:00'): string {
if (!dt) return '';
return allDay ? toDateOnly(dt) : toDatetimeLocal(dt, fallbackTime);
}
function adjustAllDayEndForDisplay(dateStr: string): string {
if (!dateStr) return '';
const d = new Date(dateStr.split('T')[0] + 'T12:00:00');
d.setDate(d.getDate() - 1);
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function adjustAllDayEndForSave(dateStr: string): string {
if (!dateStr) return '';
const d = new Date(dateStr + 'T12:00:00');
d.setDate(d.getDate() + 1);
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function nowLocal(): string {
const now = new Date();
const pad = (n: number) => n.toString().padStart(2, '0');
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`;
}
function plusOneHour(dt: string): string {
const d = new Date(dt);
d.setHours(d.getHours() + 1);
const pad = (n: number) => n.toString().padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function formatRecurrenceRule(rule: string): string {
try {
const parsed = JSON.parse(rule);
const weekdays = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
switch (parsed.type) {
case 'every_n_days':
return parsed.interval === 1 ? 'Every day' : `Every ${parsed.interval} days`;
case 'weekly':
return parsed.weekday != null ? `Weekly on ${weekdays[parsed.weekday]}` : 'Weekly';
case 'monthly_nth_weekday':
return parsed.week && parsed.weekday != null
? `Monthly on week ${parsed.week}, ${weekdays[parsed.weekday]}`
: 'Monthly';
case 'monthly_date':
return parsed.day ? `Monthly on the ${parsed.day}${ordinal(parsed.day)}` : 'Monthly';
default:
return 'Recurring';
}
} catch {
return 'Recurring';
}
}
function ordinal(n: number): string {
const s = ['th', 'st', 'nd', 'rd'];
const v = n % 100;
return s[(v - 20) % 10] || s[v] || s[0];
}
function parseRecurrenceRule(raw?: string): RecurrenceRule | null {
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
// Python weekday: 0=Monday, 6=Sunday
const WEEKDAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
// --- Types ---
export interface CreateDefaults {
start?: string;
end?: string;
allDay?: boolean;
templateData?: Partial<CalendarEvent>;
templateName?: string;
}
interface EventDetailPanelProps {
event: CalendarEvent | null;
isCreating?: boolean;
createDefaults?: CreateDefaults | null;
onClose: () => void;
onSaved?: () => void;
onDeleted?: () => void;
locationName?: string;
}
interface EditState {
title: string;
description: string;
start_datetime: string;
end_datetime: string;
all_day: boolean;
location_id: string;
calendar_id: string;
is_starred: boolean;
recurrence_type: string;
recurrence_interval: number;
recurrence_weekday: number;
recurrence_week: number;
recurrence_day: number;
}
function buildEditStateFromEvent(event: CalendarEvent): EditState {
const rule = parseRecurrenceRule(event.recurrence_rule);
const isAllDay = event.all_day;
const displayEnd = isAllDay ? adjustAllDayEndForDisplay(event.end_datetime) : event.end_datetime;
return {
title: event.title,
description: event.description || '',
start_datetime: formatForInput(event.start_datetime, isAllDay, '09:00'),
end_datetime: formatForInput(displayEnd, isAllDay, '10:00'),
all_day: isAllDay,
location_id: event.location_id?.toString() || '',
calendar_id: event.calendar_id?.toString() || '',
is_starred: event.is_starred || false,
recurrence_type: rule?.type || '',
recurrence_interval: rule?.interval || 2,
recurrence_weekday: rule?.weekday ?? 1,
recurrence_week: rule?.week || 1,
recurrence_day: rule?.day || 1,
};
}
function buildCreateState(defaults: CreateDefaults | null, defaultCalendarId: string): EditState {
const source = defaults?.templateData;
const isAllDay = source?.all_day ?? defaults?.allDay ?? false;
const defaultStart = nowLocal();
const defaultEnd = plusOneHour(defaultStart);
const rawStart = defaults?.start || defaultStart;
const rawEnd = defaults?.end || defaultEnd;
const displayEnd = isAllDay ? adjustAllDayEndForDisplay(rawEnd) : rawEnd;
const rule = parseRecurrenceRule(source?.recurrence_rule);
return {
title: source?.title || '',
description: source?.description || '',
start_datetime: formatForInput(rawStart, isAllDay, '09:00'),
end_datetime: formatForInput(displayEnd, isAllDay, '10:00'),
all_day: isAllDay,
location_id: source?.location_id?.toString() || '',
calendar_id: source?.calendar_id?.toString() || defaultCalendarId,
is_starred: source?.is_starred || false,
recurrence_type: rule?.type || '',
recurrence_interval: rule?.interval || 2,
recurrence_weekday: rule?.weekday ?? 1,
recurrence_week: rule?.week || 1,
recurrence_day: rule?.day || 1,
};
}
function buildRecurrencePayload(state: EditState): RecurrenceRule | null {
if (!state.recurrence_type) return null;
switch (state.recurrence_type) {
case 'every_n_days':
return { type: 'every_n_days', interval: state.recurrence_interval };
case 'weekly':
return { type: 'weekly' };
case 'monthly_nth_weekday':
return { type: 'monthly_nth_weekday', week: state.recurrence_week, weekday: state.recurrence_weekday };
case 'monthly_date':
return { type: 'monthly_date', day: state.recurrence_day };
default:
return null;
}
}
// --- Component ---
export default function EventDetailPanel({
event,
isCreating = false,
createDefaults,
onClose,
onSaved,
onDeleted,
locationName,
}: EventDetailPanelProps) {
const queryClient = useQueryClient();
const { data: calendars = [] } = useCalendars();
const selectableCalendars = calendars.filter((c) => !c.is_system);
const defaultCalendar = calendars.find((c) => c.is_default);
const { data: locations = [] } = useQuery({
queryKey: ['locations'],
queryFn: async () => {
const { data } = await api.get<LocationType[]>('/locations');
return data;
},
staleTime: 5 * 60 * 1000,
});
const [isEditing, setIsEditing] = useState(isCreating);
const [editState, setEditState] = useState<EditState>(() =>
isCreating
? buildCreateState(createDefaults ?? null, defaultCalendar?.id?.toString() || '')
: event
? buildEditStateFromEvent(event)
: buildCreateState(null, defaultCalendar?.id?.toString() || '')
);
const [scopeStep, setScopeStep] = useState<'edit' | 'delete' | null>(null);
const [editScope, setEditScope] = useState<'this' | 'this_and_future' | null>(null);
const [locationSearch, setLocationSearch] = useState('');
const isRecurring = !!(event?.is_recurring || event?.parent_event_id);
// Reset state when event changes
useEffect(() => {
setIsEditing(false);
setScopeStep(null);
setEditScope(null);
setLocationSearch('');
if (event) setEditState(buildEditStateFromEvent(event));
}, [event?.id]);
// Enter edit mode when creating
useEffect(() => {
if (isCreating) {
setIsEditing(true);
setEditState(buildCreateState(createDefaults ?? null, defaultCalendar?.id?.toString() || ''));
setLocationSearch('');
}
}, [isCreating, createDefaults]);
// Initialize location search text from existing location
useEffect(() => {
if (isEditing && !isCreating && event?.location_id) {
const loc = locations.find((l) => l.id === event.location_id);
if (loc) setLocationSearch(loc.name);
}
}, [isEditing, isCreating, event?.location_id, locations]);
const invalidateAll = useCallback(() => {
queryClient.invalidateQueries({ queryKey: ['calendar-events'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
queryClient.invalidateQueries({ queryKey: ['upcoming'] });
}, [queryClient]);
// --- Mutations ---
const saveMutation = useMutation({
mutationFn: async (data: EditState) => {
const rule = buildRecurrencePayload(data);
let endDt = data.end_datetime;
if (data.all_day && endDt) endDt = adjustAllDayEndForSave(endDt);
const payload: Record<string, unknown> = {
title: data.title,
description: data.description || null,
start_datetime: data.start_datetime,
end_datetime: endDt,
all_day: data.all_day,
location_id: data.location_id ? parseInt(data.location_id) : null,
calendar_id: data.calendar_id ? parseInt(data.calendar_id) : null,
is_starred: data.is_starred,
recurrence_rule: rule,
};
if (event && !isCreating) {
if (editScope) payload.edit_scope = editScope;
return api.put(`/events/${event.id}`, payload);
} else {
return api.post('/events', payload);
}
},
onSuccess: () => {
invalidateAll();
toast.success(isCreating ? 'Event created' : 'Event updated');
if (isCreating) {
onClose();
} else {
setIsEditing(false);
setEditScope(null);
}
onSaved?.();
},
onError: (error) => {
toast.error(getErrorMessage(error, isCreating ? 'Failed to create event' : 'Failed to update event'));
},
});
const deleteMutation = useMutation({
mutationFn: async () => {
const scope = editScope ? `?scope=${editScope}` : '';
await api.delete(`/events/${event!.id}${scope}`);
},
onSuccess: () => {
invalidateAll();
toast.success('Event deleted');
onClose();
onDeleted?.();
},
onError: (error) => {
toast.error(getErrorMessage(error, 'Failed to delete event'));
},
});
const executeDelete = useCallback(() => deleteMutation.mutate(), [deleteMutation]);
const { confirming: confirmingDelete, handleClick: handleDeleteClick } = useConfirmAction(executeDelete);
// --- Handlers ---
const handleEditStart = () => {
if (isRecurring) {
setScopeStep('edit');
} else {
if (event) setEditState(buildEditStateFromEvent(event));
setIsEditing(true);
}
};
const handleScopeSelect = (scope: 'this' | 'this_and_future') => {
setEditScope(scope);
if (scopeStep === 'edit') {
if (event) setEditState(buildEditStateFromEvent(event));
setIsEditing(true);
setScopeStep(null);
} else if (scopeStep === 'delete') {
// Delete with scope — execute immediately
setScopeStep(null);
// The deleteMutation will read editScope, but we need to set it first
// Since setState is async, use the mutation directly with the scope
const scopeParam = `?scope=${scope}`;
api.delete(`/events/${event!.id}${scopeParam}`).then(() => {
invalidateAll();
toast.success('Event(s) deleted');
onClose();
onDeleted?.();
}).catch((error) => {
toast.error(getErrorMessage(error, 'Failed to delete event'));
});
}
};
const handleEditCancel = () => {
setIsEditing(false);
setEditScope(null);
setLocationSearch('');
if (isCreating) {
onClose();
} else if (event) {
setEditState(buildEditStateFromEvent(event));
}
};
const handleEditSave = () => {
saveMutation.mutate(editState);
};
const handleDeleteStart = () => {
if (isRecurring) {
setScopeStep('delete');
} else {
handleDeleteClick();
}
};
// --- Render helpers ---
const updateField = <K extends keyof EditState>(key: K, value: EditState[K]) => {
setEditState((s) => ({ ...s, [key]: value }));
};
// Empty state
if (!event && !isCreating) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<Calendar className="h-8 w-8 mb-3 opacity-40" />
<p className="text-sm">Select an event to view details</p>
</div>
);
}
// View mode data
const startDate = event ? parseISO(event.start_datetime) : null;
const endDate = event?.end_datetime ? parseISO(event.end_datetime) : null;
const startStr = startDate
? event!.all_day
? format(startDate, 'EEEE, MMMM d, yyyy')
: format(startDate, 'EEEE, MMMM d, yyyy · h:mm a')
: '';
const endStr = endDate
? event!.all_day
? format(endDate, 'EEEE, MMMM d, yyyy')
: format(endDate, 'h:mm a')
: null;
const panelTitle = isCreating
? createDefaults?.templateName
? `New Event from ${createDefaults.templateName}`
: 'New Event'
: event?.title || '';
return (
<div className="flex flex-col h-full bg-card border-l border-border overflow-hidden">
{/* Header */}
<div className="px-5 py-4 border-b border-border shrink-0">
<div className="flex items-start justify-between gap-3">
{isEditing ? (
<div className="flex-1 min-w-0">
{isCreating ? (
<h3 className="font-heading text-lg font-semibold">{panelTitle}</h3>
) : (
<Input
value={editState.title}
onChange={(e) => updateField('title', e.target.value)}
className="h-8 text-base font-semibold"
placeholder="Event title"
autoFocus
/>
)}
</div>
) : scopeStep ? (
<h3 className="font-heading text-sm font-semibold">
{scopeStep === 'edit' ? 'Edit Recurring Event' : 'Delete Recurring Event'}
</h3>
) : (
<div className="flex items-center gap-3 min-w-0 flex-1">
<div
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: event?.calendar_color || 'hsl(var(--accent-color))' }}
/>
<div className="min-w-0">
<h3 className="font-heading text-lg font-semibold truncate">{event?.title}</h3>
<span className="text-xs text-muted-foreground">{event?.calendar_name}</span>
</div>
</div>
)}
<div className="flex items-center gap-1 shrink-0">
{scopeStep ? (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => setScopeStep(null)}
title="Cancel"
>
<X className="h-3.5 w-3.5" />
</Button>
) : (isEditing || isCreating) ? (
<>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-green-400 hover:text-green-300"
onClick={handleEditSave}
disabled={saveMutation.isPending}
title="Save"
>
<Save className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleEditCancel}
title="Cancel"
>
<X className="h-3.5 w-3.5" />
</Button>
</>
) : (
<>
{!event?.is_virtual && (
<>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={handleEditStart}
title="Edit event"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
{confirmingDelete ? (
<Button
variant="ghost"
onClick={handleDeleteStart}
disabled={deleteMutation.isPending}
className="h-7 px-2 bg-destructive/20 text-destructive text-[11px] font-medium"
title="Confirm delete"
>
Sure?
</Button>
) : (
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-destructive"
onClick={handleDeleteStart}
disabled={deleteMutation.isPending}
title="Delete event"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onClose}
title="Close panel"
>
<X className="h-3.5 w-3.5" />
</Button>
</>
)}
</div>
</div>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3">
{scopeStep ? (
/* Scope selection step */
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
This is a recurring event. How would you like to proceed?
</p>
<div className="flex flex-col gap-2">
<Button
variant="outline"
className="w-full justify-center"
onClick={() => handleScopeSelect('this')}
>
This event only
</Button>
<Button
variant="outline"
className="w-full justify-center"
onClick={() => handleScopeSelect('this_and_future')}
>
This and all future events
</Button>
<Button
variant="ghost"
className="w-full justify-center"
onClick={() => setScopeStep(null)}
>
Cancel
</Button>
</div>
</div>
) : (isEditing || isCreating) ? (
/* Edit / Create mode */
<div className="space-y-4">
{/* Title (only shown in body for create mode; edit mode has it in header) */}
{isCreating && (
<div className="space-y-1">
<Label htmlFor="panel-title" required>Title</Label>
<Input
id="panel-title"
value={editState.title}
onChange={(e) => updateField('title', e.target.value)}
placeholder="Event title"
required
autoFocus
/>
</div>
)}
<div className="space-y-1">
<Label htmlFor="panel-desc">Description</Label>
<Textarea
id="panel-desc"
value={editState.description}
onChange={(e) => updateField('description', e.target.value)}
placeholder="Add a description..."
rows={3}
className="text-sm resize-none"
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="panel-allday"
checked={editState.all_day}
onChange={(e) => {
const checked = (e.target as HTMLInputElement).checked;
updateField('all_day', checked);
updateField('start_datetime', formatForInput(editState.start_datetime, checked, '09:00'));
updateField('end_datetime', formatForInput(editState.end_datetime, checked, '10:00'));
}}
/>
<Label htmlFor="panel-allday">All day event</Label>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="panel-start" required>Start</Label>
<DatePicker
variant="input"
id="panel-start"
mode={editState.all_day ? 'date' : 'datetime'}
value={editState.start_datetime}
onChange={(v) => updateField('start_datetime', v)}
className="text-xs"
required
/>
</div>
<div className="space-y-1">
<Label htmlFor="panel-end">End</Label>
<DatePicker
variant="input"
id="panel-end"
mode={editState.all_day ? 'date' : 'datetime'}
value={editState.end_datetime}
onChange={(v) => updateField('end_datetime', v)}
className="text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="panel-calendar">Calendar</Label>
<Select
id="panel-calendar"
value={editState.calendar_id}
onChange={(e) => updateField('calendar_id', e.target.value)}
className="text-xs"
>
{selectableCalendars.map((cal) => (
<option key={cal.id} value={cal.id}>{cal.name}</option>
))}
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="panel-location">Location</Label>
<LocationPicker
id="panel-location"
value={locationSearch}
onChange={(val) => {
setLocationSearch(val);
if (!val) updateField('location_id', '');
}}
onSelect={async (result) => {
if (result.source === 'local' && result.location_id) {
updateField('location_id', result.location_id.toString());
} else if (result.source === 'nominatim') {
try {
const { data: newLoc } = await api.post('/locations', {
name: result.name,
address: result.address,
category: 'other',
});
queryClient.invalidateQueries({ queryKey: ['locations'] });
updateField('location_id', newLoc.id.toString());
toast.success(`Location "${result.name}" created`);
} catch {
toast.error('Failed to create location');
}
}
}}
placeholder="Search location..."
/>
</div>
</div>
{/* Recurrence */}
<div className="space-y-1">
<Label htmlFor="panel-recurrence">Recurrence</Label>
<Select
id="panel-recurrence"
value={editState.recurrence_type}
onChange={(e) => updateField('recurrence_type', e.target.value)}
className="text-xs"
>
<option value="">None</option>
<option value="every_n_days">Every X days</option>
<option value="weekly">Weekly</option>
<option value="monthly_nth_weekday">Monthly (nth weekday)</option>
<option value="monthly_date">Monthly (date)</option>
</Select>
</div>
{editState.recurrence_type === 'every_n_days' && (
<div className="space-y-1">
<Label htmlFor="panel-interval">Every how many days?</Label>
<Input
id="panel-interval"
type="number"
min={1}
max={365}
value={editState.recurrence_interval}
onChange={(e) => updateField('recurrence_interval', parseInt(e.target.value) || 1)}
className="text-xs"
/>
</div>
)}
{editState.recurrence_type === 'weekly' && (
<p className="text-xs text-muted-foreground">
Repeats every week on the same day as the start date.
</p>
)}
{editState.recurrence_type === 'monthly_nth_weekday' && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="panel-week">Week of month</Label>
<Select
id="panel-week"
value={editState.recurrence_week.toString()}
onChange={(e) => updateField('recurrence_week', parseInt(e.target.value))}
className="text-xs"
>
<option value="1">1st</option>
<option value="2">2nd</option>
<option value="3">3rd</option>
<option value="4">4th</option>
</Select>
</div>
<div className="space-y-1">
<Label htmlFor="panel-weekday">Day of week</Label>
<Select
id="panel-weekday"
value={editState.recurrence_weekday.toString()}
onChange={(e) => updateField('recurrence_weekday', parseInt(e.target.value))}
className="text-xs"
>
{WEEKDAYS.map((name, i) => (
<option key={i} value={i}>{name}</option>
))}
</Select>
</div>
</div>
)}
{editState.recurrence_type === 'monthly_date' && (
<div className="space-y-1">
<Label htmlFor="panel-day">Day of month</Label>
<Input
id="panel-day"
type="number"
min={1}
max={31}
value={editState.recurrence_day}
onChange={(e) => updateField('recurrence_day', parseInt(e.target.value) || 1)}
className="text-xs"
/>
</div>
)}
<div className="flex items-center gap-2">
<Checkbox
id="panel-starred"
checked={editState.is_starred}
onChange={(e) => updateField('is_starred', (e.target as HTMLInputElement).checked)}
/>
<Label htmlFor="panel-starred">Star this event</Label>
</div>
{/* Save / Cancel buttons at bottom of form */}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-border">
<Button variant="outline" size="sm" onClick={handleEditCancel}>
Cancel
</Button>
<Button size="sm" onClick={handleEditSave} disabled={saveMutation.isPending}>
{saveMutation.isPending ? 'Saving...' : isCreating ? 'Create' : 'Update'}
</Button>
</div>
</div>
) : (
/* View mode */
<>
{/* 2-column grid: Calendar, Starred, Start, End, Location, Recurrence */}
<div className="grid grid-cols-2 gap-3">
{/* Calendar */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Calendar className="h-3 w-3" />
Calendar
</div>
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full shrink-0"
style={{ backgroundColor: event?.calendar_color || 'hsl(var(--accent-color))' }}
/>
<span className="text-sm">{event?.calendar_name}</span>
</div>
</div>
{/* Starred */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Star className="h-3 w-3" />
Starred
</div>
{event?.is_starred ? (
<p className="text-sm text-amber-200/90">Starred</p>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
{/* Start */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Clock className="h-3 w-3" />
Start
</div>
<CopyableField value={startStr} icon={Clock} label="Start time" />
</div>
{/* End */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Clock className="h-3 w-3" />
End
</div>
{endStr ? (
<CopyableField value={endStr} icon={Clock} label="End time" />
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
{/* Location */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<MapPin className="h-3 w-3" />
Location
</div>
{locationName ? (
<CopyableField value={locationName} icon={MapPin} label="Location" />
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
{/* Recurrence */}
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<Repeat className="h-3 w-3" />
Recurrence
</div>
{isRecurring && event?.recurrence_rule ? (
<p className="text-sm">{formatRecurrenceRule(event.recurrence_rule)}</p>
) : isRecurring ? (
<p className="text-sm">Recurring event</p>
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
</div>
{/* Description — full width */}
{event?.description && (
<div className="space-y-1">
<div className="flex items-center gap-1.5 text-[11px] text-muted-foreground uppercase tracking-wider">
<AlignLeft className="h-3 w-3" />
Description
</div>
<p className="text-sm whitespace-pre-wrap">{event.description}</p>
</div>
)}
{/* Updated at */}
{event && !event.is_virtual && (
<div className="pt-2 border-t border-border">
<span className="text-[11px] text-muted-foreground">
{formatUpdatedAt(event.updated_at)}
</span>
</div>
)}
</>
)}
</div>
</div>
);
}