Calendar rewrite: 3-column layout with tree, multi-view (day/week/month/range), detail panel

This commit is contained in:
Agent Zero
2026-07-21 09:35:09 +02:00
parent b16cd5e96f
commit a69035eebb
12 changed files with 2231 additions and 530 deletions
@@ -0,0 +1,387 @@
/**
* CalendarDetail - right column detail panel for the Calendar plugin.
*
* Shows entry details (title, type, status, priority, dates, location,
* description, calendar info), subtask checklist for tasks, linked entities,
* and action buttons (Edit, Delete).
* Pattern follows DMS FileDetails.
*/
import React, { useEffect, useState } from 'react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import {
createSubtask,
getEntry,
updateSubtask,
type Calendar,
type CalendarEntry,
type Subtask,
} from '@/api/calendar';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return '-';
try {
const d = new Date(dateStr);
return d.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return dateStr;
}
}
function priorityClass(p: string): string {
switch (p) {
case 'high':
return 'bg-danger-100 text-danger-800';
case 'low':
return 'bg-secondary-100 text-secondary-700';
default:
return 'bg-warning-100 text-warning-800';
}
}
export interface CalendarDetailProps {
entry: CalendarEntry | null;
calendar: Calendar | null;
onEdit: () => void;
onDelete: () => void;
onClose: () => void;
onEntryChanged: (entry: CalendarEntry) => void;
}
export function CalendarDetail({
entry,
calendar,
onEdit,
onDelete,
onClose,
onEntryChanged,
}: CalendarDetailProps) {
const { t } = useTranslation();
const [subtasks, setSubtasks] = useState<Subtask[]>(entry?.subtasks ?? []);
const [loadingSubtasks, setLoadingSubtasks] = useState(false);
const [newSubtaskTitle, setNewSubtaskTitle] = useState('');
const [subtaskError, setSubtaskError] = useState<string | null>(null);
// Load subtasks if entry is a task and subtasks are missing
useEffect(() => {
if (!entry) {
setSubtasks([]);
return;
}
if (entry.entry_type !== 'task') {
setSubtasks([]);
return;
}
if ((entry.subtasks?.length ?? 0) > 0) {
setSubtasks(entry.subtasks ?? []);
return;
}
let cancelled = false;
setLoadingSubtasks(true);
getEntry(entry.id)
.then((full) => {
if (cancelled) return;
setSubtasks(full.subtasks ?? []);
onEntryChanged(full);
})
.catch((e: Error) => {
if (!cancelled) setSubtaskError(e.message);
})
.finally(() => {
if (!cancelled) setLoadingSubtasks(false);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entry?.id, entry?.entry_type, entry?.subtasks]);
const handleAddSubtask = async () => {
if (!entry) return;
const title = newSubtaskTitle.trim();
if (!title) return;
setSubtaskError(null);
try {
const created = await createSubtask(entry.id, { title });
setSubtasks((cur) => [...cur, created]);
setNewSubtaskTitle('');
} catch (e) {
setSubtaskError((e as Error).message ?? t('calendar.errorSave'));
}
};
const handleToggleSubtask = async (sub: Subtask) => {
if (!entry) return;
setSubtaskError(null);
const prev = sub.done;
setSubtasks((cur) => cur.map((s) => (s.id === sub.id ? { ...s, done: !prev } : s)));
try {
const updated = await updateSubtask(entry.id, sub.id, { done: !prev });
setSubtasks((cur) => cur.map((s) => (s.id === sub.id ? updated : s)));
} catch (e) {
setSubtasks((cur) => cur.map((s) => (s.id === sub.id ? { ...s, done: prev } : s)));
setSubtaskError((e as Error).message ?? t('calendar.errorSave'));
}
};
if (!entry) {
return (
<div
className="flex flex-col items-center justify-center h-full p-6 text-center"
data-testid="calendar-detail-empty"
>
<svg className="w-12 h-12 text-secondary-300 mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p className="text-sm font-medium text-secondary-600">{t('calendar.detail.noSelection')}</p>
<p className="mt-1 text-xs text-secondary-400">{t('calendar.detail.noSelectionHint')}</p>
</div>
);
}
const isTask = entry.entry_type === 'task';
return (
<div className="flex flex-col h-full" data-testid="calendar-detail">
{/* Header with close button */}
<div className="flex items-center justify-between px-4 py-3 border-b border-secondary-200">
<h3 className="text-sm font-semibold text-secondary-900 truncate" title={entry.title}>
{entry.title}
</h3>
<button
onClick={onClose}
className="p-1 rounded text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 min-h-touch min-w-touch"
aria-label={t('common.close')}
data-testid="calendar-detail-close"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto p-4">
{/* Badges */}
<div className="flex flex-wrap gap-2 mb-4">
<span
className={clsx(
'px-2 py-0.5 rounded text-xs font-medium uppercase',
isTask ? 'bg-warning-100 text-warning-800' : 'bg-primary-100 text-primary-800',
)}
>
{entry.entry_type}
</span>
<span className="px-2 py-0.5 rounded bg-secondary-100 text-secondary-700 text-xs">
{t(`calendar.status.${entry.status}`)}
</span>
<span className={clsx('px-2 py-0.5 rounded text-xs font-medium uppercase', priorityClass(entry.priority))}>
{entry.priority}
</span>
</div>
{/* Action buttons */}
<div className="grid grid-cols-2 gap-2 mb-6">
<button
onClick={onEdit}
className="inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-xs font-medium bg-primary-50 text-primary-700 hover:bg-primary-100 min-h-touch"
data-testid="calendar-detail-edit"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
{t('calendar.detail.edit')}
</button>
<button
onClick={onDelete}
className="inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-xs font-medium bg-danger-50 text-danger-700 hover:bg-danger-100 min-h-touch"
data-testid="calendar-detail-delete"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
{t('calendar.detail.delete')}
</button>
</div>
{/* Metadata section */}
<div className="space-y-3">
<h4 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide">
{t('calendar.detail.title')}
</h4>
<dl className="space-y-2">
<div className="flex justify-between text-sm">
<dt className="text-secondary-500">{t('calendar.detail.type')}</dt>
<dd className="text-secondary-900 font-medium capitalize">{entry.entry_type}</dd>
</div>
<div className="flex justify-between text-sm">
<dt className="text-secondary-500">{t('calendar.detail.status')}</dt>
<dd className="text-secondary-900 font-medium">{t(`calendar.status.${entry.status}`)}</dd>
</div>
<div className="flex justify-between text-sm">
<dt className="text-secondary-500">{t('calendar.detail.priority')}</dt>
<dd className="text-secondary-900 font-medium capitalize">{entry.priority}</dd>
</div>
<div className="flex justify-between text-sm">
<dt className="text-secondary-500">{t('calendar.detail.start')}</dt>
<dd className="text-secondary-900">{formatDate(entry.start_at)}</dd>
</div>
<div className="flex justify-between text-sm">
<dt className="text-secondary-500">{t('calendar.detail.end')}</dt>
<dd className="text-secondary-900">{formatDate(entry.end_at)}</dd>
</div>
{entry.location && (
<div className="flex justify-between text-sm">
<dt className="text-secondary-500">{t('calendar.detail.location')}</dt>
<dd className="text-secondary-900">{entry.location}</dd>
</div>
)}
{calendar && (
<div className="flex justify-between text-sm items-center">
<dt className="text-secondary-500">{t('calendar.detail.calendar')}</dt>
<dd className="text-secondary-900 font-medium flex items-center gap-1.5">
<span
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: calendar.color || '#3b82f6' }}
aria-hidden="true"
/>
{calendar.name}
</dd>
</div>
)}
</dl>
</div>
{/* Description */}
{entry.description && (
<div className="mt-6 space-y-2">
<h4 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide">
{t('calendar.detail.description')}
</h4>
<p className="text-sm text-secondary-700 whitespace-pre-wrap">{entry.description}</p>
</div>
)}
{/* Subtasks (for tasks) */}
{isTask && (
<div className="mt-6 border-t border-secondary-200 pt-4">
<h4 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide mb-2">
{t('calendar.detail.subtasks')}
</h4>
{loadingSubtasks && (
<div className="text-xs text-secondary-500 mb-2">{t('common.loading')}</div>
)}
<ul className="space-y-1 mb-3" data-testid="calendar-detail-subtask-list">
{subtasks.length === 0 && !loadingSubtasks && (
<li className="text-xs text-secondary-400 italic">
{t('calendar.detail.subtasks.empty')}
</li>
)}
{subtasks.map((sub) => (
<li
key={sub.id}
className="flex items-start gap-2"
data-testid={`calendar-detail-subtask-${sub.id}`}
>
<input
type="checkbox"
checked={sub.done}
onChange={() => handleToggleSubtask(sub)}
className="mt-1 h-4 w-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
data-testid={`calendar-detail-subtask-toggle-${sub.id}`}
/>
<span
className={clsx(
'text-sm flex-1',
sub.done && 'line-through text-secondary-400',
)}
>
{sub.title}
</span>
</li>
))}
</ul>
<form
onSubmit={(e) => {
e.preventDefault();
void handleAddSubtask();
}}
className="flex gap-2"
data-testid="calendar-detail-subtask-add-form"
>
<Input
value={newSubtaskTitle}
onChange={(e) => setNewSubtaskTitle(e.target.value)}
placeholder={t('calendar.detail.subtasks.addPlaceholder')}
data-testid="calendar-detail-subtask-add-input"
/>
<Button
type="submit"
variant="primary"
size="sm"
disabled={!newSubtaskTitle.trim()}
data-testid="calendar-detail-subtask-add-button"
>
{t('calendar.detail.subtasks.add')}
</Button>
</form>
{subtaskError && (
<div
className="mt-2 text-xs text-danger-700 bg-danger-50 border border-danger-200 rounded-md p-2"
data-testid="calendar-detail-subtask-error"
>
{subtaskError}
</div>
)}
</div>
)}
{/* Linked entities */}
{entry.links && entry.links.length > 0 && (
<div className="mt-6 border-t border-secondary-200 pt-4">
<h4 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide mb-2">
{t('calendar.detail.links')}
</h4>
<ul className="space-y-1">
{entry.links.map((link) => (
<li
key={link.id}
className="flex items-center gap-2 text-sm text-secondary-700"
>
<span className="px-1.5 py-0.5 rounded text-xs bg-secondary-100 text-secondary-600 capitalize">
{link.entity_type}
</span>
<span className="font-mono text-xs">{link.entity_id}</span>
</li>
))}
</ul>
</div>
)}
{(!entry.links || entry.links.length === 0) && (
<div className="mt-6 border-t border-secondary-200 pt-4">
<h4 className="text-xs font-semibold text-secondary-500 uppercase tracking-wide mb-2">
{t('calendar.detail.links')}
</h4>
<p className="text-xs text-secondary-400 italic">{t('calendar.detail.noLinks')}</p>
</div>
)}
</div>
</div>
);
}
export default CalendarDetail;
@@ -0,0 +1,231 @@
/**
* CalendarTree - left sidebar calendar selector for the Calendar plugin.
*
* Groups calendars by type (personal, team, project, company) with color dots,
* visibility checkboxes, and a "New Calendar" button at the bottom.
* Pattern follows DMS SourceTree.
*/
import React, { useState } from 'react';
import clsx from 'clsx';
import { useTranslation } from 'react-i18next';
import type { Calendar, CalendarType } from '@/api/calendar';
const TYPE_ORDER: CalendarType[] = ['personal', 'team', 'project', 'company'];
interface CalendarRowProps {
calendar: Calendar;
selected: boolean;
visible: boolean;
onSelect: () => void;
onToggleVisibility: () => void;
}
function CalendarRow({ calendar, selected, visible, onSelect, onToggleVisibility }: CalendarRowProps) {
return (
<div
className={clsx(
'flex items-center gap-2 px-2 py-1.5 rounded-md text-sm cursor-pointer',
'hover:bg-secondary-100 motion-safe:transition-colors',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
selected && 'bg-primary-50 text-primary-700 font-medium',
)}
style={{ paddingLeft: '20px' }}
onClick={onSelect}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelect();
}
}}
tabIndex={0}
role="button"
aria-label={calendar.name}
data-testid={`calendar-tree-row-${calendar.id}`}
>
<input
type="checkbox"
checked={visible}
onChange={(e) => {
e.stopPropagation();
onToggleVisibility();
}}
onClick={(e) => e.stopPropagation()}
className="flex-shrink-0 h-3.5 w-3.5 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
aria-label={`toggle visibility ${calendar.name}`}
data-testid={`calendar-tree-visibility-${calendar.id}`}
/>
<span
className="flex-shrink-0 w-3 h-3 rounded-full"
style={{ backgroundColor: calendar.color || '#3b82f6' }}
aria-hidden="true"
/>
<span className="truncate flex-1">{calendar.name}</span>
</div>
);
}
interface TypeSectionProps {
type: CalendarType;
calendars: Calendar[];
activeCalendarId: string | null;
visibleCalendarIds: Set<string>;
onSelect: (id: string) => void;
onToggleVisibility: (id: string) => void;
}
function TypeSection({
type,
calendars,
activeCalendarId,
visibleCalendarIds,
onSelect,
onToggleVisibility,
}: TypeSectionProps) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(true);
if (calendars.length === 0) return null;
return (
<div className="mb-1">
<div
className={clsx(
'flex items-center gap-1 px-2 py-1.5 rounded-md text-sm font-semibold cursor-pointer',
'hover:bg-secondary-100 motion-safe:transition-colors',
)}
onClick={() => setExpanded((p) => !p)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setExpanded((p) => !p);
}
}}
tabIndex={0}
role="button"
aria-expanded={expanded}
aria-label={t(`calendar.type.${type}`)}
>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setExpanded((p) => !p);
}}
className="flex-shrink-0 w-4 h-4 flex items-center justify-center text-secondary-400 hover:text-secondary-600"
aria-label={expanded ? 'Collapse' : 'Expand'}
>
<svg className="w-3 h-3 transition-transform" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={expanded ? 'M19 9l-7 7-7-7' : 'M9 5l7 7-7 7'} />
</svg>
</button>
<span className="truncate text-secondary-700">{t(`calendar.type.${type}`)}</span>
<span className="ml-auto text-xs text-secondary-400">{calendars.length}</span>
</div>
{expanded && (
<ul role="group" className="space-y-0 mt-0.5">
{calendars.map((cal) => (
<li key={cal.id}>
<CalendarRow
calendar={cal}
selected={activeCalendarId === cal.id}
visible={
visibleCalendarIds.size === 0 || visibleCalendarIds.has(cal.id)
}
onSelect={() => onSelect(cal.id)}
onToggleVisibility={() => onToggleVisibility(cal.id)}
/>
</li>
))}
</ul>
)}
</div>
);
}
export interface CalendarTreeProps {
calendars: Calendar[];
activeCalendarId: string | null;
visibleCalendarIds: Set<string>;
onSelect: (id: string) => void;
onToggleVisibility: (id: string) => void;
onCreate: () => void;
loading: boolean;
}
export function CalendarTree({
calendars,
activeCalendarId,
visibleCalendarIds,
onSelect,
onToggleVisibility,
onCreate,
loading,
}: CalendarTreeProps) {
const { t } = useTranslation();
if (loading) {
return (
<div className="space-y-2 p-2" data-testid="calendar-tree-loading">
{[1, 2, 3].map((i) => (
<div key={i} className="h-8 bg-secondary-100 rounded animate-pulse" />
))}
</div>
);
}
const grouped = new Map<CalendarType, Calendar[]>();
for (const type of TYPE_ORDER) {
grouped.set(type, []);
}
for (const cal of calendars) {
const list = grouped.get(cal.type) ?? grouped.get('personal')!;
list.push(cal);
}
return (
<nav aria-label={t('calendar.tree.title')} data-testid="calendar-tree" className="flex flex-col h-full">
<div className="px-3 py-2 border-b border-secondary-200">
<h2 className="text-sm font-semibold text-secondary-900">{t('calendar.tree.title')}</h2>
</div>
<div className="flex-1 overflow-y-auto p-2"> {calendars.length === 0 ? (
<p className="text-xs text-secondary-400 italic px-2 py-4 text-center">
{t('calendar.noCalendars')}
</p>
) : (
TYPE_ORDER.map((type) => (
<TypeSection
key={type}
type={type}
calendars={grouped.get(type) ?? []}
activeCalendarId={activeCalendarId}
visibleCalendarIds={visibleCalendarIds}
onSelect={onSelect}
onToggleVisibility={onToggleVisibility}
/>
))
)}
</div>
<div className="p-2 border-t border-secondary-200">
<button
type="button"
onClick={onCreate}
className={clsx(
'w-full inline-flex items-center justify-center gap-1.5 px-3 py-2 rounded-md text-sm font-medium',
'bg-primary-50 text-primary-700 hover:bg-primary-100 min-h-touch',
)}
data-testid="calendar-tree-new"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
{t('calendar.tree.newCalendar')}
</button>
</div>
</nav>
);
}
export default CalendarTree;
@@ -0,0 +1,207 @@
/**
* DayView - single-day view with hourly time slots.
*
* Click an empty slot -> fires `onCreateAt(date)`.
* Click an entry -> fires `onEditEntry(entry)`.
* Drag an entry to another time slot -> calls `onMoveEntry(entry, newStart)`.
*/
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx';
import type { CalendarEntry } from '@/api/calendar';
export interface DayViewProps {
visibleDay: Date;
entries: CalendarEntry[];
loading?: boolean;
onCreateAt: (date: Date) => void;
onEditEntry: (entry: CalendarEntry) => void;
onMoveEntry: (entry: CalendarEntry, newStart: Date) => void | Promise<void>;
}
const HOUR_START = 0;
const HOUR_END = 24;
const SLOT_HEIGHT = 56; // px per hour - slightly taller for single-day view
function startOfDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
function sameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
function fmtTime(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function DayView({
visibleDay,
entries,
loading,
onCreateAt,
onEditEntry,
onMoveEntry,
}: DayViewProps) {
const { t } = useTranslation();
const today = startOfDay(new Date());
const day = startOfDay(visibleDay);
const isToday = sameDay(day, today);
const weekdays = t('calendar.weekdays', { returnObjects: true }) as string[];
const months = t('calendar.months', { returnObjects: true }) as string[];
const weekdayIdx = (day.getDay() + 6) % 7; // 0 = Monday
const hours = useMemo(() => {
return Array.from({ length: HOUR_END - HOUR_START }, (_, i) => HOUR_START + i);
}, []);
const dayEntries = useMemo(() => {
return entries.filter((e) => {
if (!e.start_at) return false;
return sameDay(new Date(e.start_at), day);
});
}, [entries, day]);
const handleDragStart = (e: React.DragEvent<HTMLDivElement>, entry: CalendarEntry) => {
e.dataTransfer.setData('application/x-calendar-entry', entry.id);
e.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
if (e.dataTransfer.types.includes('application/x-calendar-entry')) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>, hour: number) => {
e.preventDefault();
const id = e.dataTransfer.getData('application/x-calendar-entry');
if (!id) return;
const entry = entries.find((x) => x.id === id);
if (!entry || !entry.start_at) return;
const oldStart = new Date(entry.start_at);
const newStart = new Date(
day.getFullYear(),
day.getMonth(),
day.getDate(),
hour,
oldStart.getMinutes(),
oldStart.getSeconds(),
);
void onMoveEntry(entry, newStart);
};
return (
<div className="flex flex-col h-full bg-white" data-testid="day-view">
{/* Header */}
<div
className={clsx(
'flex items-center gap-3 px-4 py-3 border-b border-secondary-200',
isToday && 'bg-primary-50',
)}
>
<div className="text-xs font-medium text-secondary-600 uppercase tracking-wide">
{weekdays[weekdayIdx]}
</div>
<div
className={clsx(
'text-sm font-semibold inline-flex items-center justify-center h-7 w-7 rounded-full',
isToday && 'bg-primary-600 text-white',
)}
>
{day.getDate()}
</div>
<div className="text-sm text-secondary-700">
{months[day.getMonth()]} {day.getFullYear()}
</div>
</div>
{/* Scrollable time grid */}
<div className="flex-1 overflow-y-auto" data-testid="day-view-grid">
<div className="flex">
{/* Time labels */}
<div className="w-20 flex-shrink-0">
{hours.map((hour) => (
<div
key={hour}
className="text-xs text-secondary-400 text-right pr-3 border-b border-secondary-100"
style={{ height: `${SLOT_HEIGHT}px` }}
>
{String(hour).padStart(2, '0')}:00
</div>
))}
</div>
{/* Day column */}
<div
className={clsx(
'flex-1 relative border-l border-secondary-200',
isToday && 'bg-primary-50/30',
)}
>
{/* Hour slots */}
{hours.map((hour) => (
<div
key={hour}
className="border-b border-secondary-100 hover:bg-primary-50 cursor-pointer"
style={{ height: `${SLOT_HEIGHT}px` }}
onClick={() =>
!loading &&
onCreateAt(new Date(day.getFullYear(), day.getMonth(), day.getDate(), hour, 0))
}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, hour)}
data-testid={`day-slot-${hour}`}
/>
))}
{/* Entries positioned absolutely */}
{dayEntries.map((entry) => {
if (!entry.start_at) return null;
const start = new Date(entry.start_at);
const end = entry.end_at ? new Date(entry.end_at) : new Date(start.getTime() + 60 * 60 * 1000);
const startHour = start.getHours() + start.getMinutes() / 60;
const endHour = end.getHours() + end.getMinutes() / 60;
const durationHours = Math.max(endHour - startHour, 0.5);
const top = (startHour - HOUR_START) * SLOT_HEIGHT;
const height = durationHours * SLOT_HEIGHT;
return (
<div
key={entry.id}
draggable
onDragStart={(e) => handleDragStart(e, entry)}
onClick={(e) => {
e.stopPropagation();
onEditEntry(entry);
}}
className={clsx(
'absolute left-2 right-2 rounded px-2 py-1 text-sm cursor-grab active:cursor-grabbing',
'bg-primary-100 text-primary-900 hover:bg-primary-200 overflow-hidden',
entry.entry_type === 'task' && 'bg-warning-100 text-warning-900 hover:bg-warning-200',
)}
style={{ top: `${top}px`, height: `${height}px`, minHeight: '24px' }}
title={entry.title}
data-testid={`day-entry-${entry.id}`}
>
<div className="font-medium truncate">{entry.title}</div>
<div className="text-xs opacity-75">
{fmtTime(start)}{entry.end_at ? ` - ${fmtTime(end)}` : ''}
</div>
{entry.location && (
<div className="text-xs opacity-60 truncate">@ {entry.location}</div>
)}
</div>
);
})}
</div>
</div>
</div>
</div>
);
}
export default DayView;
@@ -0,0 +1,204 @@
/**
* RangeView - agenda-style list of entries between a start and end date.
*
* Groups entries by day, showing day headers with date + weekday.
* Click an entry -> fires onEditEntry(entry).
*/
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx';
import type { CalendarEntry } from '@/api/calendar';
export interface RangeViewProps {
rangeStart: Date;
rangeEnd: Date;
entries: CalendarEntry[];
loading?: boolean;
onEditEntry: (entry: CalendarEntry) => void;
}
function startOfDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
function addDays(d: Date, n: number): Date {
const out = new Date(d);
out.setDate(out.getDate() + n);
return out;
}
function sameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
function fmtTime(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return pad(d.getHours()) + ':' + pad(d.getMinutes());
}
function fmtDayDate(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return pad(d.getDate()) + '.' + pad(d.getMonth() + 1) + '.' + d.getFullYear();
}
export function RangeView({
rangeStart,
rangeEnd,
entries,
loading,
onEditEntry,
}: RangeViewProps) {
const { t } = useTranslation();
const today = startOfDay(new Date());
const weekdays = t('calendar.weekdays', { returnObjects: true }) as string[];
const days = useMemo(() => {
if (!rangeStart || !rangeEnd) return [];
const start = startOfDay(rangeStart);
const end = startOfDay(rangeEnd);
if (end < start) return [start];
const result: Date[] = [];
let cur = start;
while (cur <= end) {
result.push(cur);
cur = addDays(cur, 1);
}
return result;
}, [rangeStart, rangeEnd]);
const entriesByDay = useMemo(() => {
const map = new Map<string, CalendarEntry[]>();
for (const entry of entries) {
if (!entry.start_at) continue;
const d = new Date(entry.start_at);
const key = d.getFullYear() + '-' + d.getMonth() + '-' + d.getDate();
const list = map.get(key) ?? [];
list.push(entry);
map.set(key, list);
}
for (const list of map.values()) {
list.sort((a, b) => {
const at = a.start_at ? new Date(a.start_at).getTime() : 0;
const bt = b.start_at ? new Date(b.start_at).getTime() : 0;
return at - bt;
});
}
return map;
}, [entries]);
if (loading) {
return (
<div className="flex items-center justify-center h-full p-6" data-testid="range-view-loading">
<div className="text-sm text-secondary-500">{t('common.loading')}</div>
</div>
);
}
if (days.length === 0) {
return (
<div className="flex items-center justify-center h-full p-6" data-testid="range-view-empty">
<div className="text-sm text-secondary-500">{t('calendar.rangeView.noEntries')}</div>
</div>
);
}
return (
<div className="flex flex-col h-full overflow-y-auto bg-white" data-testid="range-view">
{days.map((day) => {
const key = day.getFullYear() + '-' + day.getMonth() + '-' + day.getDate();
const dayEntries = entriesByDay.get(key) ?? [];
const isToday = sameDay(day, today);
const weekdayIdx = (day.getDay() + 6) % 7;
return (
<div key={key} className="border-b border-secondary-100" data-testid={'range-day-' + key}>
<div
className={clsx(
'flex items-center gap-2 px-4 py-2 sticky top-0 z-10',
isToday ? 'bg-primary-50' : 'bg-secondary-50',
)}
>
<span className="text-sm font-semibold text-secondary-900">
{weekdays[weekdayIdx]}
</span>
<span className="text-sm text-secondary-600">{fmtDayDate(day)}</span>
{dayEntries.length > 0 && (
<span className="ml-auto text-xs text-secondary-400">
{t('calendar.rangeView.entriesFor', { count: dayEntries.length })}
</span>
)}
</div>
{dayEntries.length === 0 ? (
<div className="px-4 py-2 text-xs text-secondary-400 italic">
{t('calendar.rangeView.noEntries')}
</div>
) : (
<ul className="divide-y divide-secondary-50">
{dayEntries.map((entry) => {
const start = entry.start_at ? new Date(entry.start_at) : null;
return (
<li
key={entry.id}
className="flex items-center gap-3 px-4 py-2 hover:bg-secondary-50 cursor-pointer"
onClick={() => onEditEntry(entry)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onEditEntry(entry);
}
}}
tabIndex={0}
role="button"
data-testid={'range-entry-' + entry.id}
>
<div className="w-16 flex-shrink-0 text-xs font-mono text-secondary-600">
{start ? fmtTime(start) : '-'}
</div>
<span
className={clsx(
'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium uppercase',
entry.entry_type === 'task'
? 'bg-warning-100 text-warning-800'
: 'bg-primary-100 text-primary-800',
)}
>
{entry.entry_type}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-secondary-900 truncate">
{entry.title}
</div>
{entry.location && (
<div className="text-xs text-secondary-500 truncate">{entry.location}</div>
)}
</div>
<span
className={clsx(
'flex-shrink-0 px-1.5 py-0.5 rounded text-xs',
entry.status === 'done' && 'bg-success-100 text-success-800',
entry.status === 'in_progress' && 'bg-primary-100 text-primary-800',
entry.status === 'cancelled' && 'bg-danger-100 text-danger-800',
entry.status === 'open' && 'bg-secondary-100 text-secondary-700',
)}
>
{t('calendar.status.' + entry.status)}
</span>
</li>
);
})}
</ul>
)}
</div>
);
})}
</div>
);
}
export default RangeView;
@@ -0,0 +1,227 @@
/**
* WeekView - 7-day week grid (Mon-Sun) with hourly time slots.
*
* Click an empty slot -> fires `onCreateAt(date)`.
* Click an entry -> fires `onEditEntry(entry)`.
* Drag an entry onto another day/slot -> calls `onMoveEntry(entry, newStart)`.
*/
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx';
import type { CalendarEntry } from '@/api/calendar';
export interface WeekViewProps {
/** Monday of the visible week. */
visibleWeek: Date;
entries: CalendarEntry[];
loading?: boolean;
onCreateAt: (date: Date) => void;
onEditEntry: (entry: CalendarEntry) => void;
onMoveEntry: (entry: CalendarEntry, newStart: Date) => void | Promise<void>;
}
const HOUR_START = 0;
const HOUR_END = 24;
const SLOT_HEIGHT = 48; // px per hour
function startOfDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
function addDays(d: Date, n: number): Date {
const out = new Date(d);
out.setDate(out.getDate() + n);
return out;
}
function sameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
function fmtTime(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function WeekView({
visibleWeek,
entries,
loading,
onCreateAt,
onEditEntry,
onMoveEntry,
}: WeekViewProps) {
const { t } = useTranslation();
const today = startOfDay(new Date());
const weekdays = t('calendar.weekdays', { returnObjects: true }) as string[];
const weekDays = useMemo(() => {
return Array.from({ length: 7 }, (_, i) => addDays(startOfDay(visibleWeek), i));
}, [visibleWeek]);
const hours = useMemo(() => {
return Array.from({ length: HOUR_END - HOUR_START }, (_, i) => HOUR_START + i);
}, []);
const entriesByDay = useMemo(() => {
const map = new Map<string, CalendarEntry[]>();
for (const entry of entries) {
if (!entry.start_at) continue;
const d = new Date(entry.start_at);
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
const list = map.get(key) ?? [];
list.push(entry);
map.set(key, list);
}
return map;
}, [entries]);
const handleDragStart = (e: React.DragEvent<HTMLDivElement>, entry: CalendarEntry) => {
e.dataTransfer.setData('application/x-calendar-entry', entry.id);
e.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
if (e.dataTransfer.types.includes('application/x-calendar-entry')) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>, day: Date, hour: number) => {
e.preventDefault();
const id = e.dataTransfer.getData('application/x-calendar-entry');
if (!id) return;
const entry = entries.find((x) => x.id === id);
if (!entry || !entry.start_at) return;
const oldStart = new Date(entry.start_at);
const newStart = new Date(
day.getFullYear(),
day.getMonth(),
day.getDate(),
hour,
oldStart.getMinutes(),
oldStart.getSeconds(),
);
void onMoveEntry(entry, newStart);
};
return (
<div className="flex flex-col h-full bg-white" data-testid="week-view">
{/* Header row: weekday names + date numbers */}
<div className="flex border-b border-secondary-200 bg-secondary-50">
<div className="w-16 flex-shrink-0" />
{weekDays.map((day, i) => {
const isToday = sameDay(day, today);
return (
<div
key={i}
className={clsx(
'flex-1 text-center px-2 py-2 border-l border-secondary-200',
isToday && 'bg-primary-50',
)}
>
<div className="text-xs font-medium text-secondary-600 uppercase tracking-wide">
{weekdays[i]}
</div>
<div
className={clsx(
'text-sm font-semibold mt-0.5 inline-flex items-center justify-center h-6 w-6 rounded-full',
isToday && 'bg-primary-600 text-white',
)}
>
{day.getDate()}
</div>
</div>
);
})}
</div>
{/* Scrollable time grid */}
<div className="flex-1 overflow-y-auto" data-testid="week-view-grid">
{/* Time column + day columns */}
<div className="flex">
{/* Time labels */}
<div className="w-16 flex-shrink-0">
{hours.map((hour) => (
<div
key={hour}
className="text-xs text-secondary-400 text-right pr-2 border-b border-secondary-100"
style={{ height: `${SLOT_HEIGHT}px` }}
>
{String(hour).padStart(2, '0')}:00
</div>
))}
</div>
{/* Day columns */}
{weekDays.map((day, dayIdx) => {
const key = `${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`;
const dayEntries = entriesByDay.get(key) ?? [];
const isToday = sameDay(day, today);
return (
<div
key={dayIdx}
className={clsx(
'flex-1 relative border-l border-secondary-200',
isToday && 'bg-primary-50/30',
)}
>
{/* Hour slots */}
{hours.map((hour) => (
<div
key={hour}
className="border-b border-secondary-100 hover:bg-primary-50 cursor-pointer"
style={{ height: `${SLOT_HEIGHT}px` }}
onClick={() => !loading && onCreateAt(new Date(day.getFullYear(), day.getMonth(), day.getDate(), hour, 0))}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, day, hour)}
data-testid={`week-slot-${dayIdx}-${hour}`}
/>
))}
{/* Entries positioned absolutely */}
{dayEntries.map((entry) => {
if (!entry.start_at) return null;
const start = new Date(entry.start_at);
const end = entry.end_at ? new Date(entry.end_at) : new Date(start.getTime() + 60 * 60 * 1000);
const startHour = start.getHours() + start.getMinutes() / 60;
const endHour = end.getHours() + end.getMinutes() / 60;
const durationHours = Math.max(endHour - startHour, 0.5);
const top = (startHour - HOUR_START) * SLOT_HEIGHT;
const height = durationHours * SLOT_HEIGHT;
return (
<div
key={entry.id}
draggable
onDragStart={(e) => handleDragStart(e, entry)}
onClick={(e) => {
e.stopPropagation();
onEditEntry(entry);
}}
className={clsx(
'absolute left-1 right-1 rounded px-1.5 py-1 text-xs cursor-grab active:cursor-grabbing',
'bg-primary-100 text-primary-900 hover:bg-primary-200 overflow-hidden',
entry.entry_type === 'task' && 'bg-warning-100 text-warning-900 hover:bg-warning-200',
)}
style={{ top: `${top}px`, height: `${height}px`, minHeight: '20px' }}
title={entry.title}
data-testid={`week-entry-${entry.id}`}
>
<div className="font-medium truncate">{entry.title}</div>
<div className="text-xs opacity-75">{fmtTime(start)}</div>
</div>
);
})}
</div>
);
})}
</div>
</div>
</div>
);
}
export default WeekView;