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 35c546d..6d31d63 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", + "options": "list\nkanban\ncalendar", "default": "list" }, { diff --git a/frontend/src/components/TaskCalendar.tsx b/frontend/src/components/TaskCalendar.tsx new file mode 100644 index 0000000..b58a5a8 --- /dev/null +++ b/frontend/src/components/TaskCalendar.tsx @@ -0,0 +1,268 @@ +import { useMemo, useState } from "react" +import { + addDays, + addMonths, + addWeeks, + eachDayOfInterval, + endOfMonth, + endOfWeek, + format, + isSameMonth, + isToday, + startOfMonth, + startOfWeek, + subDays, + subMonths, + subWeeks, +} from "date-fns" +import { HugeiconsIcon } from "@hugeicons/react" +import { ArrowLeft01Icon, ArrowRight01Icon } from "@hugeicons/core-free-icons" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" +import { TASK_STATUS_COLOR, TASK_PRIORITY_VARIANT } from "@/lib/variants" +import type { HiveTask } from "@/types" + +type CalendarMode = "month" | "week" | "day" + +interface TaskCalendarProps { + tasks: HiveTask[] + onTaskClick: (task: HiveTask) => void + /** How many task chips to show per day in month view before "+N more". */ + maxPerDay?: number +} + +const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] +const MODES: CalendarMode[] = ["month", "week", "day"] + +/** + * Task calendar with Month / Week / Day modes. Tasks are laid out on their due + * date; tasks without a due date are surfaced in a tray beneath the grid so + * they aren't silently hidden. + */ +export function TaskCalendar({ tasks, onTaskClick, maxPerDay = 3 }: TaskCalendarProps) { + const [mode, setMode] = useState("month") + const [cursor, setCursor] = useState(() => new Date()) + + const { tasksByDay, undated } = useMemo(() => { + const byDay = new Map() + const noDate: HiveTask[] = [] + for (const task of tasks) { + if (!task.due_date) { + noDate.push(task) + continue + } + const key = task.due_date.slice(0, 10) + const list = byDay.get(key) + if (list) list.push(task) + else byDay.set(key, [task]) + } + return { tasksByDay: byDay, undated: noDate } + }, [tasks]) + + const dayTasks = (day: Date) => tasksByDay.get(format(day, "yyyy-MM-dd")) ?? [] + + const goToday = () => setCursor(new Date()) + const goPrev = () => + setCursor((c) => (mode === "month" ? subMonths(c, 1) : mode === "week" ? subWeeks(c, 1) : subDays(c, 1))) + const goNext = () => + setCursor((c) => (mode === "month" ? addMonths(c, 1) : mode === "week" ? addWeeks(c, 1) : addDays(c, 1))) + + const title = + mode === "month" + ? format(cursor, "MMMM yyyy") + : mode === "week" + ? `${format(startOfWeek(cursor), "MMM d")} – ${format(endOfWeek(cursor), "MMM d, yyyy")}` + : format(cursor, "EEEE, MMM d, yyyy") + + const monthDays = useMemo( + () => eachDayOfInterval({ start: startOfWeek(startOfMonth(cursor)), end: endOfWeek(endOfMonth(cursor)) }), + [cursor], + ) + const weekDays = useMemo( + () => eachDayOfInterval({ start: startOfWeek(cursor), end: endOfWeek(cursor) }), + [cursor], + ) + + const chip = (task: HiveTask) => ( + + ) + + return ( +
+ {/* Toolbar */} +
+

{title}

+
+
+ {MODES.map((m) => ( + + ))} +
+ + + +
+
+ + {/* Month view */} + {mode === "month" && ( +
+
+ {WEEKDAYS.map((d) => ( +
+ {d} +
+ ))} +
+
+ {monthDays.map((day) => { + const list = dayTasks(day) + const inMonth = isSameMonth(day, cursor) + return ( +
+
+ + {format(day, "d")} + +
+
+ {list.slice(0, maxPerDay).map(chip)} + {list.length > maxPerDay && ( +
+{list.length - maxPerDay} more
+ )} +
+
+ ) + })} +
+
+ )} + + {/* Week view */} + {mode === "week" && ( +
+
+ {weekDays.map((day) => ( +
+
+ {format(day, "EEE")} + + {format(day, "d")} + +
+
+ {dayTasks(day).length === 0 ? ( +

+ ) : ( + dayTasks(day).map(chip) + )} +
+
+ ))} +
+
+ )} + + {/* Day view */} + {mode === "day" && ( +
+
+ {format(cursor, "EEEE")} + + {format(cursor, "d")} + +
+
+ {dayTasks(cursor).length === 0 ? ( +

No tasks due on this day.

+ ) : ( + dayTasks(cursor).map((task) => ( + + )) + )} +
+
+ )} + + {/* Tasks with no due date */} + {undated.length > 0 && ( +
+

No due date ({undated.length})

+
+ {undated.map((task) => ( + + ))} +
+
+ )} +
+ ) +} diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 4c7267c..f64082c 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -153,7 +153,7 @@ export function AppSidebar({ for (const [k, v] of Object.entries(filters)) { if (v) params.set(k, v as string) } - if (view.view_type === "kanban") params.set("view", "kanban") + if (view.view_type && view.view_type !== "list") params.set("view", view.view_type) return { ...view, to: `/tasks?${params.toString()}` } }) }, [views]) diff --git a/frontend/src/pages/TasksPage.tsx b/frontend/src/pages/TasksPage.tsx index 4bc6097..8c8de1b 100644 --- a/frontend/src/pages/TasksPage.tsx +++ b/frontend/src/pages/TasksPage.tsx @@ -14,6 +14,7 @@ import { Add01Icon, LeftToRightListBulletIcon, DashboardSquare01Icon, + Calendar01Icon, FloppyDiskIcon, MoreHorizontalIcon, RepeatIcon, @@ -88,6 +89,7 @@ import { } from "@/components/ui/breadcrumb" import { CreateTaskDialog } from "@/components/CreateTaskDialog" import { TaskKanban } from "@/components/TaskKanban" +import { TaskCalendar } from "@/components/TaskCalendar" import { TaskDetailSheet } from "@/components/TaskDetailSheet" import { usePinnedTasks } from "@/context/PinnedTasksContext" import { useCelebration } from "@/hooks/useTaskCelebration" @@ -265,7 +267,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" + const viewMode = (searchParams.get("view") ?? "list") as "list" | "kanban" | "calendar" const { data: activeView } = useFrappeGetDoc( "Hive View", @@ -412,7 +414,7 @@ export function TasksPage() { for (const [k, v] of Object.entries(filters)) { if (v) params.set(k, v) } - if (viewMode === "kanban") params.set("view", "kanban") + if (viewMode !== "list") params.set("view", viewMode) navigate(`/tasks?${params.toString()}`, { replace: true }) } catch { toast.error("Failed to save view") @@ -643,6 +645,15 @@ export function TasksPage() { > + + ) : viewMode === "calendar" ? ( +
+ +

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

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

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

diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 9176434..1503f5b 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" + view_type: "list" | "kanban" | "calendar" filters_json: string is_public: 0 | 1 owner: string