Calendar rewrite: 3-column layout with tree, multi-view (day/week/month/range), detail panel
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user