From e49b2ba2d71efc02656cfe246908b60d1442df01 Mon Sep 17 00:00:00 2001 From: paryanineil <35301792+paryanineil@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:27:49 +0530 Subject: [PATCH 1/2] feat(tasks): add Gantt timeline view for tasks Adds a Timeline view to the Tasks page, alongside List, Kanban and Calendar. Each task is drawn as a horizontal bar spanning its start_date -> due_date on a scrollable day axis, grouped by project. - New TaskTimeline component: sticky task labels, status-colored bars, today highlighting, and an "Unscheduled" tray for tasks with no dates so none are hidden - Timeline toggle button added to the Tasks view switcher - Saveable as a named view: adds "timeline" to Hive View.view_type Co-Authored-By: Claude Opus 4.8 --- .../bwh_hive/doctype/hive_view/hive_view.json | 2 +- frontend/src/components/TaskTimeline.tsx | 173 ++++++++++++++++++ frontend/src/pages/TasksPage.tsx | 28 ++- frontend/src/types.ts | 2 +- 4 files changed, 201 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/TaskTimeline.tsx 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..b042864 --- /dev/null +++ b/frontend/src/components/TaskTimeline.tsx @@ -0,0 +1,173 @@ +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 + +/** 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 ? startOfDay(new Date(t.start_date)) : null + const d = t.due_date ? startOfDay(new Date(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..9a2a52f 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", @@ -331,6 +333,11 @@ export function TasksPage() { }, ) + const projectTitles = useMemo( + () => Object.fromEntries((projects ?? []).map((p) => [p.name, p.title])), + [projects], + ) + const { data: milestones } = useFrappeGetDocList( "Hive Milestone", { @@ -654,6 +661,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 +1023,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 From 086b252a94b4881ba60d61ccdea173062f734c1f Mon Sep 17 00:00:00 2001 From: paryanineil <35301792+paryanineil@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:09:48 +0530 Subject: [PATCH 2/2] fix(timeline): parse date-only values as local, reuse existing projectMap Addresses review feedback: - Date-only strings ("yyyy-MM-dd") were passed to `new Date()`, which V8 parses as UTC midnight. For users west of UTC that resolved to the previous local day, shifting every bar one column to the left. Parse via an explicit local time component instead. - Drop the redundant `projectTitles` memo and pass the pre-existing `projectMap` (same name -> title lookup) through to TaskTimeline. Co-Authored-By: Claude Opus 4.8 --- frontend/src/components/TaskTimeline.tsx | 13 +++++++++++-- frontend/src/pages/TasksPage.tsx | 7 +------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/TaskTimeline.tsx b/frontend/src/components/TaskTimeline.tsx index b042864..2f822b6 100644 --- a/frontend/src/components/TaskTimeline.tsx +++ b/frontend/src/components/TaskTimeline.tsx @@ -22,10 +22,19 @@ interface TaskTimelineProps { 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 ? startOfDay(new Date(t.start_date)) : null - const d = t.due_date ? startOfDay(new Date(t.due_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 diff --git a/frontend/src/pages/TasksPage.tsx b/frontend/src/pages/TasksPage.tsx index 9a2a52f..d1c7dac 100644 --- a/frontend/src/pages/TasksPage.tsx +++ b/frontend/src/pages/TasksPage.tsx @@ -333,11 +333,6 @@ export function TasksPage() { }, ) - const projectTitles = useMemo( - () => Object.fromEntries((projects ?? []).map((p) => [p.name, p.title])), - [projects], - ) - const { data: milestones } = useFrappeGetDocList( "Hive Milestone", { @@ -858,7 +853,7 @@ export function TasksPage() { ) : viewMode === "timeline" ? (
- +

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