openclaw/apps/web/app/components/workspace/object-timeline.tsx

536 lines
17 KiB
TypeScript

"use client";
import { useMemo, useState, useCallback, useRef, useEffect } from "react";
import {
parseISO, format, differenceInDays, addDays, startOfDay,
eachDayOfInterval, eachWeekOfInterval, eachMonthOfInterval,
min as dateMin, max as dateMax, addMonths, subMonths,
} from "date-fns";
import type { TimelineZoom } from "@/lib/object-filters";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type Field = {
id: string;
name: string;
type: string;
enum_values?: string[];
enum_colors?: string[];
};
type TimelineItem = {
id: string;
title: string;
startDate: Date;
endDate: Date;
group?: string;
color: string;
entry: Record<string, unknown>;
};
export type TimelineDateChangePayload = {
entryId: string;
newStartDate: string;
newEndDate: string;
};
type ObjectTimelineProps = {
objectName: string;
fields: Field[];
entries: Record<string, unknown>[];
startDateField: string;
endDateField?: string;
groupField?: string;
zoom: TimelineZoom;
onZoomChange: (zoom: TimelineZoom) => void;
members?: Array<{ id: string; name: string }>;
onEntryClick?: (entryId: string) => void;
onEntryDateChange?: (payload: TimelineDateChangePayload) => void;
};
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const ZOOM_CONFIG: Record<TimelineZoom, { dayWidth: number; headerFormat: string }> = {
day: { dayWidth: 80, headerFormat: "MMM d" },
week: { dayWidth: 30, headerFormat: "MMM d" },
month: { dayWidth: 10, headerFormat: "MMM yyyy" },
quarter: { dayWidth: 4, headerFormat: "QQQ yyyy" },
};
const ROW_HEIGHT = 40;
const HEADER_HEIGHT = 50;
const SIDEBAR_WIDTH = 200;
const BAR_HEIGHT = 28;
const HANDLE_WIDTH = 8;
const PALETTE = [
"#3b82f6", "#22c55e", "#f59e0b", "#ef4444", "#8b5cf6",
"#06b6d4", "#ec4899", "#14b8a6", "#f97316", "#6366f1",
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function safeString(val: unknown): string {
if (val == null) {return "";}
if (typeof val === "string") {return val;}
if (typeof val === "number" || typeof val === "boolean" || typeof val === "bigint") {return String(val);}
if (typeof val === "object") {return JSON.stringify(val);}
return "";
}
function resolveTitle(entry: Record<string, unknown>, fields: Field[]): string {
const titleField = fields.find((f) =>
f.type === "text" && /name|title/i.test(f.name),
) ?? fields.find((f) => f.type === "text");
return titleField ? safeString(entry[titleField.name]) : safeString(entry.id);
}
function resolveColor(entry: Record<string, unknown>, fields: Field[], idx: number): string {
const enumField = fields.find((f) => f.type === "enum" && f.enum_colors?.length);
if (enumField) {
const val = safeString(entry[enumField.name]);
const i = enumField.enum_values?.indexOf(val) ?? -1;
if (i >= 0 && enumField.enum_colors![i]) {return enumField.enum_colors![i];}
}
return PALETTE[idx % PALETTE.length];
}
function parseItems(
entries: Record<string, unknown>[],
fields: Field[],
startDateField: string,
endDateField?: string,
groupField?: string,
): TimelineItem[] {
const items: TimelineItem[] = [];
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
const rawStart = safeString(entry[startDateField]);
if (!rawStart) {continue;}
try {
const startDate = parseISO(rawStart);
if (Number.isNaN(startDate.getTime())) {continue;}
let endDate: Date;
if (endDateField) {
const rawEnd = safeString(entry[endDateField]);
if (rawEnd) {
const ed = parseISO(rawEnd);
endDate = Number.isNaN(ed.getTime()) ? addDays(startDate, 1) : ed;
} else {
endDate = addDays(startDate, 1);
}
} else {
endDate = addDays(startDate, 1);
}
if (endDate <= startDate) {endDate = addDays(startDate, 1);}
items.push({
id: safeString(entry.entry_id ?? entry.id),
title: resolveTitle(entry, fields),
startDate,
endDate,
group: groupField ? safeString(entry[groupField]) : undefined,
color: resolveColor(entry, fields, i),
entry,
});
} catch { /* skip */ }
}
return items.toSorted((a, b) => a.startDate.getTime() - b.startDate.getTime());
}
function getTimelineBounds(items: TimelineItem[], zoom: TimelineZoom): { start: Date; end: Date } {
if (items.length === 0) {
const now = new Date();
return { start: subMonths(now, 1), end: addMonths(now, 2) };
}
const earliest = dateMin(items.map((i) => i.startDate));
const latest = dateMax(items.map((i) => i.endDate));
const paddingDays = zoom === "day" ? 3 : zoom === "week" ? 7 : zoom === "month" ? 14 : 30;
return {
start: addDays(earliest, -paddingDays),
end: addDays(latest, paddingDays),
};
}
function getHeaderTicks(start: Date, end: Date, zoom: TimelineZoom): { date: Date; label: string }[] {
switch (zoom) {
case "day":
return eachDayOfInterval({ start, end }).map((d) => ({ date: d, label: format(d, "MMM d") }));
case "week":
return eachWeekOfInterval({ start, end }, { weekStartsOn: 1 }).map((d) => ({ date: d, label: format(d, "MMM d") }));
case "month":
return eachMonthOfInterval({ start, end }).map((d) => ({ date: d, label: format(d, "MMM yyyy") }));
case "quarter":
return eachMonthOfInterval({ start, end })
.filter((d) => d.getMonth() % 3 === 0)
.map((d) => ({ date: d, label: format(d, "QQQ yyyy") }));
}
}
function toISODate(d: Date): string { return format(d, "yyyy-MM-dd"); }
// ---------------------------------------------------------------------------
// Draggable bar
// ---------------------------------------------------------------------------
function DraggableBar({
item,
x,
w,
dayWidth,
onEntryClick,
onEntryDateChange,
}: {
item: TimelineItem;
x: number;
w: number;
dayWidth: number;
onEntryClick?: (id: string) => void;
onEntryDateChange?: (payload: TimelineDateChangePayload) => void;
}) {
const [drag, setDrag] = useState<"move" | "resize-left" | "resize-right" | null>(null);
const [deltaX, setDeltaX] = useState(0);
const [deltaW, setDeltaW] = useState(0);
const startXRef = useRef(0);
const editable = !!onEntryDateChange;
const snapToDays = useCallback((px: number) => {
return Math.round(px / dayWidth) * dayWidth;
}, [dayWidth]);
const pxToDays = useCallback((px: number) => {
return Math.round(px / dayWidth);
}, [dayWidth]);
const handlePointerDown = useCallback((e: React.PointerEvent, mode: "move" | "resize-left" | "resize-right") => {
if (!editable) {return;}
e.preventDefault();
e.stopPropagation();
startXRef.current = e.clientX;
setDrag(mode);
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}, [editable]);
const handlePointerMove = useCallback((e: React.PointerEvent) => {
if (!drag) {return;}
const rawDelta = e.clientX - startXRef.current;
const snapped = snapToDays(rawDelta);
if (drag === "move") {
setDeltaX(snapped);
} else if (drag === "resize-left") {
// shrink/grow from left: move x right and shrink width
setDeltaX(snapped);
setDeltaW(-snapped);
} else {
setDeltaW(snapped);
}
}, [drag, snapToDays]);
const handlePointerUp = useCallback((e: React.PointerEvent) => {
if (!drag || !onEntryDateChange) { setDrag(null); return; }
const rawDelta = e.clientX - startXRef.current;
const daysDelta = pxToDays(rawDelta);
if (daysDelta === 0 && drag === "move") {
setDrag(null);
setDeltaX(0);
setDeltaW(0);
return;
}
let newStart = item.startDate;
let newEnd = item.endDate;
if (drag === "move") {
newStart = addDays(item.startDate, daysDelta);
newEnd = addDays(item.endDate, daysDelta);
} else if (drag === "resize-left") {
newStart = addDays(item.startDate, daysDelta);
if (newStart >= newEnd) {newStart = addDays(newEnd, -1);}
} else {
newEnd = addDays(item.endDate, daysDelta);
if (newEnd <= newStart) {newEnd = addDays(newStart, 1);}
}
onEntryDateChange({
entryId: item.id,
newStartDate: toISODate(newStart),
newEndDate: toISODate(newEnd),
});
setDrag(null);
setDeltaX(0);
setDeltaW(0);
}, [drag, onEntryDateChange, item, pxToDays]);
const renderX = x + deltaX;
const renderW = Math.max(w + deltaW, dayWidth * 0.5);
return (
<div
className="absolute rounded flex items-center text-[11px] truncate select-none"
style={{
left: renderX,
width: renderW,
top: (ROW_HEIGHT - BAR_HEIGHT) / 2,
height: BAR_HEIGHT,
backgroundColor: item.color,
color: "#fff",
zIndex: drag ? 20 : 2,
cursor: editable ? (drag === "move" ? "grabbing" : "grab") : "pointer",
opacity: drag ? 0.85 : 1,
transition: drag ? "none" : "left 0.15s, width 0.15s",
}}
onPointerDown={(e) => handlePointerDown(e, "move")}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onClick={(e) => { if (!drag) { e.stopPropagation(); onEntryClick?.(item.id); } }}
title={`${item.title}\n${format(item.startDate, "MMM d")} - ${format(item.endDate, "MMM d")}`}
>
{/* Left resize handle */}
{editable && (
<div
className="absolute left-0 top-0 bottom-0 cursor-ew-resize hover:bg-white/20 rounded-l"
style={{ width: HANDLE_WIDTH }}
onPointerDown={(e) => handlePointerDown(e, "resize-left")}
/>
)}
<span className="px-2 truncate flex-1">{renderW > 60 ? (item.title || "Untitled") : ""}</span>
{/* Right resize handle */}
{editable && (
<div
className="absolute right-0 top-0 bottom-0 cursor-ew-resize hover:bg-white/20 rounded-r"
style={{ width: HANDLE_WIDTH }}
onPointerDown={(e) => handlePointerDown(e, "resize-right")}
/>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Empty state
// ---------------------------------------------------------------------------
function TimelineEmptyState({ reason }: { reason: "no-fields" | "no-items" }) {
return (
<div className="flex flex-col items-center justify-center py-16 gap-2">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ color: "var(--color-text-muted)", opacity: 0.4 }}>
<path d="M8 6h13" /><path d="M8 12h13" /><path d="M8 18h13" /><path d="M3 6h.01" /><path d="M3 12h.01" /><path d="M3 18h.01" />
</svg>
<span className="text-sm" style={{ color: "var(--color-text-muted)" }}>
{reason === "no-fields"
? "No date fields configured for timeline view. Open view settings to select start/end dates."
: "No items with dates to display in this view."}
</span>
</div>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function ObjectTimeline({
objectName: _objectName,
fields,
entries,
startDateField,
endDateField,
groupField,
zoom,
onZoomChange,
members: _members,
onEntryClick,
onEntryDateChange,
}: ObjectTimelineProps) {
const containerRef = useRef<HTMLDivElement>(null);
const items = useMemo(
() => parseItems(entries, fields, startDateField, endDateField, groupField),
[entries, fields, startDateField, endDateField, groupField],
);
const { start: timelineStart, end: timelineEnd } = useMemo(
() => getTimelineBounds(items, zoom),
[items, zoom],
);
const { dayWidth } = ZOOM_CONFIG[zoom];
const totalDays = differenceInDays(timelineEnd, timelineStart) + 1;
const totalWidth = totalDays * dayWidth;
const ticks = useMemo(
() => getHeaderTicks(timelineStart, timelineEnd, zoom),
[timelineStart, timelineEnd, zoom],
);
const groups = useMemo(() => {
if (!groupField) {return [{ name: "", items }];}
const groupMap = new Map<string, TimelineItem[]>();
for (const item of items) {
const g = item.group || "Ungrouped";
if (!groupMap.has(g)) {groupMap.set(g, []);}
groupMap.get(g)!.push(item);
}
return Array.from(groupMap.entries()).map(([name, gItems]) => ({ name, items: gItems }));
}, [items, groupField]);
const flatRows = useMemo(() => {
const rows: { type: "group" | "item"; group?: string; item?: TimelineItem }[] = [];
for (const g of groups) {
if (groupField && g.name) {rows.push({ type: "group", group: g.name });}
for (const item of g.items) {rows.push({ type: "item", item });}
}
return rows;
}, [groups, groupField]);
const dateToX = useCallback(
(date: Date) => differenceInDays(date, timelineStart) * dayWidth,
[timelineStart, dayWidth],
);
useEffect(() => {
if (!containerRef.current) {return;}
const todayX = dateToX(new Date());
containerRef.current.scrollLeft = Math.max(0, todayX - 300);
}, [dateToX]);
if (!startDateField) {return <TimelineEmptyState reason="no-fields" />;}
if (items.length === 0) {return <TimelineEmptyState reason="no-items" />;}
const todayX = dateToX(startOfDay(new Date()));
const zoomLevels: TimelineZoom[] = ["day", "week", "month", "quarter"];
return (
<div className="w-full">
<div className="flex items-center justify-between mb-3 px-1">
<div className="text-[12px]" style={{ color: "var(--color-text-muted)" }}>
{items.length} item{items.length !== 1 ? "s" : ""}
</div>
<div className="flex items-center gap-2">
<span className="text-[11px]" style={{ color: "var(--color-text-muted)" }}>Zoom:</span>
<div className="flex rounded-lg border overflow-hidden" style={{ borderColor: "var(--color-border)" }}>
{zoomLevels.map((z) => (
<button
key={z}
type="button"
onClick={() => onZoomChange(z)}
className="text-[11px] px-3 py-1 capitalize transition-colors"
style={{
background: z === zoom ? "var(--color-accent)" : "var(--color-surface)",
color: z === zoom ? "#fff" : "var(--color-text-muted)",
borderRight: z !== "quarter" ? "1px solid var(--color-border)" : undefined,
}}
>
{z}
</button>
))}
</div>
</div>
</div>
<div className="rounded-lg border overflow-hidden" style={{ borderColor: "var(--color-border)", background: "var(--color-bg)" }}>
<div className="flex">
<div
className="flex-shrink-0 border-r"
style={{ width: SIDEBAR_WIDTH, borderColor: "var(--color-border)", background: "var(--color-surface)" }}
>
<div
className="border-b px-3 flex items-center text-[11px] font-medium"
style={{ height: HEADER_HEIGHT, borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
>
Name
</div>
{flatRows.map((row, i) => (
<div
key={i}
className={`px-3 flex items-center border-b truncate ${row.type === "group" ? "text-[10px] font-semibold uppercase tracking-wider" : "text-[12px] cursor-pointer hover:bg-[var(--color-surface-hover)]"}`}
style={{
height: ROW_HEIGHT,
borderColor: "var(--color-border)",
color: row.type === "group" ? "var(--color-text-muted)" : "var(--color-text)",
background: row.type === "group" ? "var(--color-surface-hover)" : undefined,
}}
onClick={() => row.item && onEntryClick?.(row.item.id)}
>
{row.type === "group" ? row.group : row.item?.title || "Untitled"}
</div>
))}
</div>
<div
ref={containerRef}
className="flex-1 overflow-x-auto overflow-y-hidden"
style={{ maxHeight: `calc(100vh - 280px)` }}
>
<div style={{ width: totalWidth, position: "relative" }}>
<div className="border-b flex" style={{ height: HEADER_HEIGHT, borderColor: "var(--color-border)" }}>
{ticks.map((tick, i) => {
const tx = dateToX(tick.date);
const nextX = i < ticks.length - 1 ? dateToX(ticks[i + 1].date) : totalWidth;
return (
<div
key={i}
className="absolute border-r flex items-end pb-1 px-2 text-[10px]"
style={{ left: tx, width: nextX - tx, height: HEADER_HEIGHT, borderColor: "var(--color-border)", color: "var(--color-text-muted)" }}
>
{tick.label}
</div>
);
})}
</div>
{flatRows.map((row, i) => (
<div
key={i}
className="relative border-b"
style={{
height: ROW_HEIGHT,
borderColor: "var(--color-border)",
background: row.type === "group" ? "var(--color-surface-hover)" : undefined,
}}
>
{row.item && (() => {
const barItem = row.item;
const bx = dateToX(barItem.startDate);
const bw = Math.max(dateToX(barItem.endDate) - bx, dayWidth * 0.5);
return (
<DraggableBar
item={barItem}
x={bx}
w={bw}
dayWidth={dayWidth}
onEntryClick={onEntryClick}
onEntryDateChange={onEntryDateChange}
/>
);
})()}
</div>
))}
{todayX >= 0 && todayX <= totalWidth && (
<div
className="absolute top-0 bottom-0 pointer-events-none"
style={{ left: todayX, width: 2, backgroundColor: "var(--color-accent)", opacity: 0.6, zIndex: 10 }}
/>
)}
</div>
</div>
</div>
</div>
</div>
);
}