Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bwh_hive/bwh_hive/doctype/hive_view/hive_view.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"fieldname": "view_type",
"fieldtype": "Select",
"label": "View Type",
"options": "list\nkanban\ncalendar",
"options": "list\nkanban\ncalendar\ntimeline",
"default": "list"
},
{
Expand Down
182 changes: 182 additions & 0 deletions frontend/src/components/TaskTimeline.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>
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 })
Comment on lines +64 to +66

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unbounded DOM node count on wide date ranges

eachDayOfInterval returns one Date per day, and the render loop produces one div DOM node per entry in the header — no virtualisation. For a dataset where the earliest start_date and latest due_date span a year or more, the header alone generates 365+ nodes on every render. At 500 tasks (the query limit) covering different calendar years this is entirely realistic. Consider capping the visible window or switching to a virtual-scroll approach if performance becomes an issue.

Prompt To Fix With AI
This is a comment left during a code review.
Path: frontend/src/components/TaskTimeline.tsx
Line: 55-57

Comment:
**Unbounded DOM node count on wide date ranges**

`eachDayOfInterval` returns one `Date` per day, and the render loop produces one `div` DOM node per entry in the header — no virtualisation. For a dataset where the earliest `start_date` and latest `due_date` span a year or more, the header alone generates 365+ nodes on every render. At 500 tasks (the query limit) covering different calendar years this is entirely realistic. Consider capping the visible window or switching to a virtual-scroll approach if performance becomes an issue.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Codex

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left as-is for now: the axis spans only the min/max dates of the tasks currently in view (the page filters apply first), so in practice it is weeks-to-months rather than years, and a day cell is a single lightweight div. I would rather not add virtualisation complexity pre-emptively — happy to cap the window or switch to virtual scroll in a follow-up if you would prefer it hardened now.

🤖 Addressed by Claude Code

const byProject = new Map<string, { task: HiveTask; start: Date; end: Date }[]>()
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 (
<div className="rounded-md border p-12 text-center text-sm text-muted-foreground">
No tasks to show.
</div>
)
}

const gridW = days.length * COL_W
const offset = (d: Date) => differenceInCalendarDays(d, rangeStart)

return (
<div className="space-y-3">
{days.length > 0 && (
<div className="overflow-x-auto rounded-md border">
<div style={{ width: LABEL_W + gridW }}>
{/* Header: day axis */}
<div className="flex border-b bg-muted/40">
<div className="sticky left-0 z-20 shrink-0 border-r bg-muted/40 px-3 py-1.5 text-xs font-medium text-muted-foreground" style={{ width: LABEL_W }}>
Task
</div>
{days.map((d) => (
<div
key={d.toISOString()}
className={cn(
"shrink-0 border-r py-1 text-center text-[11px] leading-tight",
isToday(d) && "bg-primary/10 font-semibold text-primary",
)}
style={{ width: COL_W }}
>
<div className="text-muted-foreground">{format(d, "EEEEE")}</div>
<div>{format(d, "d")}</div>
</div>
))}
</div>

{/* Project groups */}
{groups.map((g) => (
<div key={g.project}>
<div className="flex border-b bg-muted/20">
<div className="sticky left-0 z-10 shrink-0 bg-muted/20 px-3 py-1 text-xs font-semibold" style={{ width: LABEL_W }}>
{g.title}
</div>
<div style={{ width: gridW }} />
</div>
{g.items.map(({ task, start, end }) => {
const left = offset(start) * COL_W
const width = (offset(end) - offset(start) + 1) * COL_W
return (
<div key={task.name} className="flex border-b last:border-b-0">
<div className="sticky left-0 z-10 flex shrink-0 items-center border-r bg-background px-3 py-1.5 text-xs" style={{ width: LABEL_W }}>
<span className="truncate" title={task.title}>{task.title}</span>
</div>
<div className="relative py-2" style={{ width: gridW }}>
<button
type="button"
onClick={() => onTaskClick(task)}
title={`${task.title} · ${format(start, "MMM d")} – ${format(end, "MMM d")}`}
className={cn(
"absolute top-1/2 flex h-5 -translate-y-1/2 items-center overflow-hidden rounded px-1.5 text-[11px] font-medium text-white shadow-sm transition-opacity hover:opacity-90",
TASK_STATUS_COLOR[task.status] ?? "bg-muted-foreground",
)}
style={{ left: left + 2, width: Math.max(width - 4, COL_W - 4) }}
>
<span className="truncate">{task.title}</span>
</button>
</div>
</div>
)
})}
</div>
))}
</div>
</div>
)}

{unscheduled.length > 0 && (
<div className="rounded-md border p-3">
<p className="mb-2 text-xs font-medium text-muted-foreground">
Unscheduled ({unscheduled.length}) — no start or due date
</p>
<div className="flex flex-wrap gap-1.5">
{unscheduled.map((t) => (
<button
key={t.name}
type="button"
onClick={() => onTaskClick(t)}
title={t.title}
className="flex max-w-[220px] items-center gap-1.5 rounded bg-card px-2 py-1 text-xs shadow-sm ring-1 ring-border transition-colors hover:bg-accent"
>
<span className={cn("size-1.5 shrink-0 rounded-full", TASK_STATUS_COLOR[t.status] ?? "bg-muted-foreground/40")} />
<span className="truncate">{t.title}</span>
</button>
))}
</div>
</div>
)}
</div>
)
}
23 changes: 21 additions & 2 deletions frontend/src/pages/TasksPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
LeftToRightListBulletIcon,
DashboardSquare01Icon,
Calendar01Icon,
ChartBarLineIcon,
FloppyDiskIcon,
MoreHorizontalIcon,
RepeatIcon,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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<HiveView>(
"Hive View",
Expand Down Expand Up @@ -654,6 +656,15 @@ export function TasksPage() {
>
<HugeiconsIcon icon={Calendar01Icon} strokeWidth={2} className="size-4" />
</Button>
<Button
variant={viewMode === "timeline" ? "secondary" : "ghost"}
size="icon"
className="h-7 w-7"
aria-label="Timeline view"
onClick={() => setViewMode("timeline")}
>
<HugeiconsIcon icon={ChartBarLineIcon} strokeWidth={2} className="size-4" />
</Button>
</div>
<DropdownMenu>
<DropdownMenuTrigger
Expand Down Expand Up @@ -840,6 +851,14 @@ export function TasksPage() {
{(search || statusFilter !== "all" || priorityFilter !== "all" || projectFilter !== "all" || assigneeFilter !== "all") && " matching filters"}
</p>
</div>
) : viewMode === "timeline" ? (
<div className="space-y-2">
<TaskTimeline tasks={filteredTasks} projectTitles={projectMap} onTaskClick={handleTaskClick} />
<p className="text-xs text-muted-foreground">
{filteredTasks.length} task{filteredTasks.length !== 1 ? "s" : ""}
{(search || statusFilter !== "all" || priorityFilter !== "all" || projectFilter !== "all" || assigneeFilter !== "all") && " matching filters"}
</p>
</div>
) : (
<div className="space-y-4">
<div className="overflow-x-auto rounded-md border">
Expand Down Expand Up @@ -999,7 +1018,7 @@ export function TasksPage() {
</div>
)}
<p className="text-xs text-muted-foreground">
View type: {viewMode === "kanban" ? "Kanban" : viewMode === "calendar" ? "Calendar" : "List"}
View type: {viewMode === "kanban" ? "Kanban" : viewMode === "calendar" ? "Calendar" : viewMode === "timeline" ? "Timeline" : "List"}
</p>
</div>
<DialogFooter>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading