diff --git a/bwh_hive/bwh_hive/doctype/hive_view/hive_view.json b/bwh_hive/bwh_hive/doctype/hive_view/hive_view.json index 6d31d63..057b7b5 100644 --- a/bwh_hive/bwh_hive/doctype/hive_view/hive_view.json +++ b/bwh_hive/bwh_hive/doctype/hive_view/hive_view.json @@ -27,7 +27,7 @@ "fieldname": "view_type", "fieldtype": "Select", "label": "View Type", - "options": "list\nkanban\ncalendar", + "options": "list\nkanban\ncalendar\ntimeline", "default": "list" }, { diff --git a/frontend/src/components/TaskTimeline.tsx b/frontend/src/components/TaskTimeline.tsx new file mode 100644 index 0000000..2f822b6 --- /dev/null +++ b/frontend/src/components/TaskTimeline.tsx @@ -0,0 +1,182 @@ +import { useMemo } from "react" +import { + differenceInCalendarDays, + eachDayOfInterval, + format, + isToday, + max as maxDate, + min as minDate, + startOfDay, +} from "date-fns" +import { cn } from "@/lib/utils" +import { TASK_STATUS_COLOR } from "@/lib/variants" +import type { HiveTask } from "@/types" + +interface TaskTimelineProps { + tasks: HiveTask[] + /** Map of project name → display title, for group headers. */ + projectTitles: Record + onTaskClick: (task: HiveTask) => void +} + +const COL_W = 34 // px per day column +const LABEL_W = 220 // px sticky task-label column + +/** + * Parse a Frappe date-only value ("yyyy-MM-dd") as *local* midnight. + * `new Date("2024-01-15")` is parsed as UTC midnight, which lands on the + * previous day for anyone west of UTC — appending a time forces local parsing. + */ +function parseLocalDate(value: string): Date { + return startOfDay(new Date(`${value.slice(0, 10)}T00:00:00`)) +} + +/** Resolve a task's [start, end] range from start_date / due_date (either may be missing). */ +function taskRange(t: HiveTask): [Date, Date] | null { + const s = t.start_date ? parseLocalDate(t.start_date) : null + const d = t.due_date ? parseLocalDate(t.due_date) : null + const start = s ?? d + const end = d ?? s + if (!start || !end) return null + return start.getTime() <= end.getTime() ? [start, end] : [end, start] +} + +/** + * Gantt-style timeline: each task is a horizontal bar spanning its start_date → + * due_date on a scrollable day axis, grouped by project. Tasks with no dates are + * listed in an "Unscheduled" tray so none are hidden. + */ +export function TaskTimeline({ tasks, projectTitles, onTaskClick }: TaskTimelineProps) { + const { days, groups, unscheduled, rangeStart } = useMemo(() => { + const scheduled: { task: HiveTask; start: Date; end: Date }[] = [] + const noDates: HiveTask[] = [] + for (const t of tasks) { + const r = taskRange(t) + if (!r) { + noDates.push(t) + continue + } + scheduled.push({ task: t, start: r[0], end: r[1] }) + } + if (scheduled.length === 0) { + return { days: [] as Date[], groups: [], unscheduled: noDates, rangeStart: startOfDay(new Date()) } + } + const rStart = minDate(scheduled.map((s) => s.start)) + const rEnd = maxDate(scheduled.map((s) => s.end)) + const allDays = eachDayOfInterval({ start: rStart, end: rEnd }) + const byProject = new Map() + for (const s of scheduled) { + const arr = byProject.get(s.task.project) ?? [] + arr.push(s) + byProject.set(s.task.project, arr) + } + const grouped = [...byProject.entries()] + .map(([project, items]) => ({ + project, + title: projectTitles[project] ?? project, + items: items.sort((a, b) => a.start.getTime() - b.start.getTime()), + })) + .sort((a, b) => a.title.localeCompare(b.title)) + return { days: allDays, groups: grouped, unscheduled: noDates, rangeStart: rStart } + }, [tasks, projectTitles]) + + if (days.length === 0 && unscheduled.length === 0) { + return ( +
+ No tasks to show. +
+ ) + } + + const gridW = days.length * COL_W + const offset = (d: Date) => differenceInCalendarDays(d, rangeStart) + + return ( +
+ {days.length > 0 && ( +
+
+ {/* Header: day axis */} +
+
+ Task +
+ {days.map((d) => ( +
+
{format(d, "EEEEE")}
+
{format(d, "d")}
+
+ ))} +
+ + {/* Project groups */} + {groups.map((g) => ( +
+
+
+ {g.title} +
+
+
+ {g.items.map(({ task, start, end }) => { + const left = offset(start) * COL_W + const width = (offset(end) - offset(start) + 1) * COL_W + return ( +
+
+ {task.title} +
+
+ +
+
+ ) + })} +
+ ))} +
+
+ )} + + {unscheduled.length > 0 && ( +
+

+ Unscheduled ({unscheduled.length}) — no start or due date +

+
+ {unscheduled.map((t) => ( + + ))} +
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/TasksPage.tsx b/frontend/src/pages/TasksPage.tsx index 8c8de1b..d1c7dac 100644 --- a/frontend/src/pages/TasksPage.tsx +++ b/frontend/src/pages/TasksPage.tsx @@ -15,6 +15,7 @@ import { LeftToRightListBulletIcon, DashboardSquare01Icon, Calendar01Icon, + ChartBarLineIcon, FloppyDiskIcon, MoreHorizontalIcon, RepeatIcon, @@ -90,6 +91,7 @@ import { import { CreateTaskDialog } from "@/components/CreateTaskDialog" import { TaskKanban } from "@/components/TaskKanban" import { TaskCalendar } from "@/components/TaskCalendar" +import { TaskTimeline } from "@/components/TaskTimeline" import { TaskDetailSheet } from "@/components/TaskDetailSheet" import { usePinnedTasks } from "@/context/PinnedTasksContext" import { useCelebration } from "@/hooks/useTaskCelebration" @@ -267,7 +269,7 @@ export function TasksPage() { const priorityFilter = searchParams.get("priority") ?? "all" const projectFilter = searchParams.get("project") ?? "all" const assigneeFilter = searchParams.get("assignee") ?? "all" - const viewMode = (searchParams.get("view") ?? "list") as "list" | "kanban" | "calendar" + const viewMode = (searchParams.get("view") ?? "list") as "list" | "kanban" | "calendar" | "timeline" const { data: activeView } = useFrappeGetDoc( "Hive View", @@ -654,6 +656,15 @@ export function TasksPage() { > +
+ ) : viewMode === "timeline" ? ( +
+ +

+ {filteredTasks.length} task{filteredTasks.length !== 1 ? "s" : ""} + {(search || statusFilter !== "all" || priorityFilter !== "all" || projectFilter !== "all" || assigneeFilter !== "all") && " matching filters"} +

+
) : (
@@ -999,7 +1018,7 @@ export function TasksPage() {
)}

- View type: {viewMode === "kanban" ? "Kanban" : viewMode === "calendar" ? "Calendar" : "List"} + View type: {viewMode === "kanban" ? "Kanban" : viewMode === "calendar" ? "Calendar" : viewMode === "timeline" ? "Timeline" : "List"}

diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 1503f5b..0858677 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -186,7 +186,7 @@ export interface HiveView { name: string label: string emoji: string - view_type: "list" | "kanban" | "calendar" + view_type: "list" | "kanban" | "calendar" | "timeline" filters_json: string is_public: 0 | 1 owner: string