From 4e0b1ac6b901c49c6a274c1a34b6e81dd2ac9720 Mon Sep 17 00:00:00 2001 From: fccview Date: Thu, 12 Mar 2026 12:36:33 +0000 Subject: [PATCH 01/26] A very productive week --- app/(loggedInRoutes)/kanban/layout.tsx | 25 ++ app/(loggedInRoutes)/kanban/page.tsx | 25 ++ app/(loggedInRoutes)/tasks/page.tsx | 2 +- .../Checklists/Checklist.tsx | 8 +- .../Checklists/ChecklistsPageClient.tsx | 2 +- .../Checklists/Parts/ChecklistClient.tsx | 10 +- .../Parts/ChecklistTypeSelector.tsx | 4 +- .../Parts/Common/ChecklistHeader.tsx | 12 +- .../Parts/Common/ChecklistModals.tsx | 8 +- .../Home/Parts/ChecklistHome.tsx | 12 +- .../Kanban/ArchivedItems.tsx | 3 + .../Parts => }/Kanban/ArchivedItemsModal.tsx | 0 .../FeatureComponents/Kanban/CalendarView.tsx | 177 +++++++++ .../KanbanBoard.tsx => Kanban/Kanban.tsx} | 147 ++++--- .../KanbanItem.tsx => Kanban/KanbanCard.tsx} | 77 +++- .../KanbanCardDetail.tsx} | 363 ++++++++++++++---- .../Parts => }/Kanban/KanbanColumn.tsx | 10 +- .../Parts => }/Kanban/KanbanItemContent.tsx | 0 .../Parts => }/Kanban/KanbanItemTimer.tsx | 0 .../Kanban/KanbanPageClient.tsx | 313 +++++++++++++++ .../Kanban/StatusManagementModal.tsx | 0 .../Kanban/StatusManager.tsx | 3 + .../Kanban/TimeEntriesAccordion.tsx | 0 .../InternalLinkComponent.tsx | 2 +- .../PublicView/Parts/PublicChecklistBody.tsx | 4 +- .../Parts/PublicChecklistHeader.tsx | 2 +- .../Sidebar/Parts/SidebarItem.tsx | 2 +- .../GlobalComponents/Cards/ChecklistCard.tsx | 2 +- .../Cards/ChecklistGridItem.tsx | 2 +- .../Cards/ChecklistListItem.tsx | 2 +- app/_consts/kanban.ts | 29 ++ app/_hooks/kanban/index.tsx | 4 + app/_hooks/kanban/useCalendarView.ts | 91 +++++ app/_hooks/kanban/useKanban.ts | 297 ++++++++++++++ app/_hooks/{ => kanban}/useKanbanItem.tsx | 126 ++++-- app/_hooks/kanban/useKanbanReminders.ts | 51 +++ app/_hooks/useChecklist.tsx | 6 +- app/_hooks/useChecklistHome.tsx | 4 +- app/_hooks/useKanbanBoard.tsx | 267 ------------- .../actions/checklist-item/bulk-operations.ts | 2 +- app/_server/actions/checklist-item/crud.ts | 21 +- .../actions/checklist-item/sub-items.ts | 2 +- app/_server/actions/checklist/converters.ts | 31 +- app/_server/actions/checklist/parsers.ts | 4 +- app/_server/actions/checklist/readers.ts | 5 +- app/_server/actions/kanban/calendar.ts | 36 ++ app/_server/actions/kanban/index.ts | 12 + app/_server/actions/kanban/items.ts | 257 +++++++++++++ app/_server/actions/sharing/index.ts | 1 + app/_server/actions/sharing/queries.ts | 23 ++ app/_server/actions/stats/index.ts | 2 +- app/_translations/en.json | 45 +++ app/_types/checklist.ts | 13 +- app/_types/enums.ts | 9 + app/_types/index.ts | 2 + app/_utils/checklist-utils.ts | 61 ++- app/_utils/client-parser-utils.ts | 53 ++- app/_utils/kanban-utils.tsx | 49 --- app/_utils/kanban/calendar-utils.ts | 120 ++++++ app/_utils/kanban/index.tsx | 82 ++++ app/_utils/kanban/reminder-utils.ts | 39 ++ app/_utils/yaml-metadata-utils.ts | 11 +- app/api/checklists/route.ts | 6 +- app/api/kanban/[boardId]/calendar/route.ts | 43 +++ .../[boardId]/items/[itemId]/assign/route.ts | 51 +++ .../items/[itemId]/reminder/route.ts | 100 +++++ .../kanban/[boardId]/items/[itemId]/route.ts | 96 +++++ .../[boardId]/items/[itemId]/status/route.ts | 59 +++ app/api/kanban/[boardId]/items/route.ts | 56 +++ app/api/kanban/[boardId]/route.ts | 152 ++++++++ app/api/kanban/[boardId]/statuses/route.ts | 54 +++ app/api/kanban/route.ts | 149 +++++++ app/api/summary/route.ts | 2 +- .../tasks/[taskId]/items/[itemIndex]/route.ts | 2 +- .../items/[itemIndex]/status/route.ts | 2 +- app/api/tasks/[taskId]/items/route.ts | 2 +- app/api/tasks/[taskId]/route.ts | 6 +- .../[taskId]/statuses/[statusId]/route.ts | 4 +- app/api/tasks/[taskId]/statuses/route.ts | 4 +- app/api/tasks/route.ts | 4 +- package.json | 5 +- tests/mock-data/constants.ts | 2 +- 82 files changed, 3167 insertions(+), 604 deletions(-) create mode 100644 app/(loggedInRoutes)/kanban/layout.tsx create mode 100644 app/(loggedInRoutes)/kanban/page.tsx create mode 100644 app/_components/FeatureComponents/Kanban/ArchivedItems.tsx rename app/_components/FeatureComponents/{Checklists/Parts => }/Kanban/ArchivedItemsModal.tsx (100%) create mode 100644 app/_components/FeatureComponents/Kanban/CalendarView.tsx rename app/_components/FeatureComponents/{Checklists/Parts/Kanban/KanbanBoard.tsx => Kanban/Kanban.tsx} (73%) rename app/_components/FeatureComponents/{Checklists/Parts/Kanban/KanbanItem.tsx => Kanban/KanbanCard.tsx} (70%) rename app/_components/FeatureComponents/{Checklists/Parts/Kanban/SubtaskModal.tsx => Kanban/KanbanCardDetail.tsx} (56%) rename app/_components/FeatureComponents/{Checklists/Parts => }/Kanban/KanbanColumn.tsx (94%) rename app/_components/FeatureComponents/{Checklists/Parts => }/Kanban/KanbanItemContent.tsx (100%) rename app/_components/FeatureComponents/{Checklists/Parts => }/Kanban/KanbanItemTimer.tsx (100%) create mode 100644 app/_components/FeatureComponents/Kanban/KanbanPageClient.tsx rename app/_components/FeatureComponents/{Checklists/Parts => }/Kanban/StatusManagementModal.tsx (100%) create mode 100644 app/_components/FeatureComponents/Kanban/StatusManager.tsx rename app/_components/FeatureComponents/{Checklists/Parts => }/Kanban/TimeEntriesAccordion.tsx (100%) create mode 100644 app/_consts/kanban.ts create mode 100644 app/_hooks/kanban/index.tsx create mode 100644 app/_hooks/kanban/useCalendarView.ts create mode 100644 app/_hooks/kanban/useKanban.ts rename app/_hooks/{ => kanban}/useKanbanItem.tsx (69%) create mode 100644 app/_hooks/kanban/useKanbanReminders.ts delete mode 100644 app/_hooks/useKanbanBoard.tsx create mode 100644 app/_server/actions/kanban/calendar.ts create mode 100644 app/_server/actions/kanban/index.ts create mode 100644 app/_server/actions/kanban/items.ts delete mode 100644 app/_utils/kanban-utils.tsx create mode 100644 app/_utils/kanban/calendar-utils.ts create mode 100644 app/_utils/kanban/index.tsx create mode 100644 app/_utils/kanban/reminder-utils.ts create mode 100644 app/api/kanban/[boardId]/calendar/route.ts create mode 100644 app/api/kanban/[boardId]/items/[itemId]/assign/route.ts create mode 100644 app/api/kanban/[boardId]/items/[itemId]/reminder/route.ts create mode 100644 app/api/kanban/[boardId]/items/[itemId]/route.ts create mode 100644 app/api/kanban/[boardId]/items/[itemId]/status/route.ts create mode 100644 app/api/kanban/[boardId]/items/route.ts create mode 100644 app/api/kanban/[boardId]/route.ts create mode 100644 app/api/kanban/[boardId]/statuses/route.ts create mode 100644 app/api/kanban/route.ts diff --git a/app/(loggedInRoutes)/kanban/layout.tsx b/app/(loggedInRoutes)/kanban/layout.tsx new file mode 100644 index 00000000..a113d3ad --- /dev/null +++ b/app/(loggedInRoutes)/kanban/layout.tsx @@ -0,0 +1,25 @@ +import { TasksClient } from "@/app/_components/FeatureComponents/Checklists/TasksClient"; +import { getCategories } from "@/app/_server/actions/category"; +import { getCurrentUser } from "@/app/_server/actions/users"; +import { Modes } from "@/app/_types/enums"; +import { sanitizeUserForClient } from "@/app/_utils/user-sanitize-utils"; + +export default async function KanbanLayout({ + children, +}: { + children: React.ReactNode; +}) { + const [checklistCategories, userRecord] = await Promise.all([ + getCategories(Modes.CHECKLISTS), + getCurrentUser(), + ]); + + const categories = checklistCategories.data || []; + const user = sanitizeUserForClient(userRecord); + + return ( + + {children} + + ); +} diff --git a/app/(loggedInRoutes)/kanban/page.tsx b/app/(loggedInRoutes)/kanban/page.tsx new file mode 100644 index 00000000..d25bd099 --- /dev/null +++ b/app/(loggedInRoutes)/kanban/page.tsx @@ -0,0 +1,25 @@ +import { getUserChecklists } from "@/app/_server/actions/checklist"; +import { getCurrentUser } from "@/app/_server/actions/users"; +import { KanbanPageClient } from "@/app/_components/FeatureComponents/Kanban/KanbanPageClient"; +import { Checklist } from "@/app/_types"; +import { sanitizeUserForClient } from "@/app/_utils/user-sanitize-utils"; + +export const dynamic = "force-dynamic"; + +export default async function KanbanPage() { + const [listsResult, userRecord] = await Promise.all([ + getUserChecklists(), + getCurrentUser(), + ]); + + const lists = listsResult.success && listsResult.data ? listsResult.data : []; + const kanbanLists = lists.filter((list) => list.type === "kanban" || list.type === "task") as Checklist[]; + const user = sanitizeUserForClient(userRecord); + + return ( + + ); +} diff --git a/app/(loggedInRoutes)/tasks/page.tsx b/app/(loggedInRoutes)/tasks/page.tsx index 17feb82b..7f12bb0e 100644 --- a/app/(loggedInRoutes)/tasks/page.tsx +++ b/app/(loggedInRoutes)/tasks/page.tsx @@ -13,7 +13,7 @@ export default async function TasksPage() { ]); const lists = listsResult.success && listsResult.data ? listsResult.data : []; - const taskLists = lists.filter((list) => list.type === "task") as Checklist[]; + const taskLists = lists.filter((list) => list.type === "kanban" || list.type === "task") as Checklist[]; const user = sanitizeUserForClient(userRecord); return ( diff --git a/app/_components/FeatureComponents/Checklists/Checklist.tsx b/app/_components/FeatureComponents/Checklists/Checklist.tsx index a523ddf0..f6c7419f 100644 --- a/app/_components/FeatureComponents/Checklists/Checklist.tsx +++ b/app/_components/FeatureComponents/Checklists/Checklist.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import { Checklist } from "@/app/_types"; -import { KanbanBoard } from "@/app/_components/FeatureComponents/Checklists/Parts/Kanban/KanbanBoard"; +import { Kanban } from "@/app/_components/FeatureComponents/Kanban/Kanban"; import { useChecklist } from "@/app/_hooks/useChecklist"; import { ChecklistHeader } from "@/app/_components/FeatureComponents/Checklists/Parts/Common/ChecklistHeader"; import { ChecklistHeading } from "@/app/_components/FeatureComponents/Checklists/Parts/Common/ChecklistHeading"; @@ -116,7 +116,7 @@ export const ChecklistView = ({ ), }, ]} - onRemove={() => {}} + onRemove={() => { }} > )} @@ -136,7 +136,7 @@ export const ChecklistView = ({ ), }, ]} - onRemove={() => {}} + onRemove={() => { }} > )} @@ -165,7 +165,7 @@ export const ChecklistView = ({ /> ) : (
- +
)} diff --git a/app/_components/FeatureComponents/Checklists/ChecklistsPageClient.tsx b/app/_components/FeatureComponents/Checklists/ChecklistsPageClient.tsx index 8af9b43c..e013a303 100644 --- a/app/_components/FeatureComponents/Checklists/ChecklistsPageClient.tsx +++ b/app/_components/FeatureComponents/Checklists/ChecklistsPageClient.tsx @@ -72,7 +72,7 @@ export const ChecklistsPageClient = ({ !list.items.every((item) => isItemCompleted(item, list.type)) ); } else if (checklistFilter === "task") { - filtered = filtered.filter((list) => list.type === "task"); + filtered = filtered.filter((list) => list.type === "kanban" || list.type === "task"); } else if (checklistFilter === "simple") { filtered = filtered.filter((list) => list.type === "simple"); } diff --git a/app/_components/FeatureComponents/Checklists/Parts/ChecklistClient.tsx b/app/_components/FeatureComponents/Checklists/Parts/ChecklistClient.tsx index d8f4c837..d2903903 100644 --- a/app/_components/FeatureComponents/Checklists/Parts/ChecklistClient.tsx +++ b/app/_components/FeatureComponents/Checklists/Parts/ChecklistClient.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { useRouter } from "next/navigation"; import { Category, Checklist, SanitisedUser } from "@/app/_types"; import { ChecklistView } from "@/app/_components/FeatureComponents/Checklists/Checklist"; -import { KanbanBoard } from "@/app/_components/FeatureComponents/Checklists/Parts/Kanban/KanbanBoard"; +import { Kanban } from "@/app/_components/FeatureComponents/Kanban/Kanban"; import { ChecklistHeader } from "@/app/_components/FeatureComponents/Checklists/Parts/Common/ChecklistHeader"; import { ShareModal } from "@/app/_components/GlobalComponents/Modals/SharingModals/ShareModal"; import { ConfirmModal } from "@/app/_components/GlobalComponents/Modals/ConfirmationModals/ConfirmModal"; @@ -131,7 +131,7 @@ export const ChecklistClient = ({ }); const renderContent = () => { - if (localChecklist.type === "task") { + if (localChecklist.type === "kanban" || localChecklist.type === "task") { return (
- +
); } @@ -193,11 +193,11 @@ export const ChecklistClient = ({ currentType: localChecklist.type === "simple" ? t("checklists.simpleChecklist") - : t("checklists.taskProject"), + : t("checklists.kanbanBoard"), newType: getNewType(localChecklist.type) === "simple" ? t("checklists.simpleChecklist") - : t("checklists.taskProject"), + : t("checklists.kanbanBoard"), })} confirmText={t("checklists.convert")} /> diff --git a/app/_components/FeatureComponents/Checklists/Parts/ChecklistTypeSelector.tsx b/app/_components/FeatureComponents/Checklists/Parts/ChecklistTypeSelector.tsx index 61ff38c3..f9fec8bf 100644 --- a/app/_components/FeatureComponents/Checklists/Parts/ChecklistTypeSelector.tsx +++ b/app/_components/FeatureComponents/Checklists/Parts/ChecklistTypeSelector.tsx @@ -25,7 +25,7 @@ export const ChecklistTypeSelector = ({
- {([ChecklistsTypes.SIMPLE, ChecklistsTypes.TASK] as const).map((type) => ( + {([ChecklistsTypes.SIMPLE, ChecklistsTypes.KANBAN] as const).map((type) => (
{type === ChecklistsTypes.SIMPLE diff --git a/app/_components/FeatureComponents/Checklists/Parts/Common/ChecklistHeader.tsx b/app/_components/FeatureComponents/Checklists/Parts/Common/ChecklistHeader.tsx index 5818b1d6..f22ba2aa 100644 --- a/app/_components/FeatureComponents/Checklists/Parts/Common/ChecklistHeader.tsx +++ b/app/_components/FeatureComponents/Checklists/Parts/Common/ChecklistHeader.tsx @@ -128,12 +128,12 @@ export const ChecklistHeader = ({ }} className="h-10 w-10 p-0" title={ - checklist.type === ChecklistsTypes.TASK + checklist.type === ChecklistsTypes.KANBAN || checklist.type === ChecklistsTypes.TASK ? t('checklists.convertToSimpleChecklist') : t('checklists.convertToTaskProject') } > - {checklist.type === ChecklistsTypes.TASK ? ( + {checklist.type === ChecklistsTypes.KANBAN || checklist.type === ChecklistsTypes.TASK ? ( ) : ( @@ -174,11 +174,11 @@ export const ChecklistHeader = ({ { type: "item" as const, label: - checklist.type === ChecklistsTypes.TASK - ? "Convert to Simple Checklist" - : "Convert to Task Project", + checklist.type === ChecklistsTypes.KANBAN || checklist.type === ChecklistsTypes.TASK + ? t("checklists.convertToSimpleChecklist") + : t("checklists.convertToKanbanBoard"), icon: - checklist.type === ChecklistsTypes.TASK ? ( + checklist.type === ChecklistsTypes.KANBAN || checklist.type === ChecklistsTypes.TASK ? ( ) : ( diff --git a/app/_components/FeatureComponents/Checklists/Parts/Common/ChecklistModals.tsx b/app/_components/FeatureComponents/Checklists/Parts/Common/ChecklistModals.tsx index 01f66ace..8448e1d0 100644 --- a/app/_components/FeatureComponents/Checklists/Parts/Common/ChecklistModals.tsx +++ b/app/_components/FeatureComponents/Checklists/Parts/Common/ChecklistModals.tsx @@ -1,7 +1,7 @@ import { ShareModal } from "@/app/_components/GlobalComponents/Modals/SharingModals/ShareModal"; import { ConfirmModal } from "@/app/_components/GlobalComponents/Modals/ConfirmationModals/ConfirmModal"; import { BulkPasteModal } from "@/app/_components/GlobalComponents/Modals/BulkPasteModal/BulkPasteModal"; -import { Checklist } from "@/app/_types"; +import { Checklist, ChecklistType } from "@/app/_types"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; @@ -16,7 +16,7 @@ interface ChecklistModalsProps { showBulkPasteModal: boolean; setShowBulkPasteModal: (show: boolean) => void; handleConfirmConversion: () => void; - getNewType: (type: "simple" | "task") => "simple" | "task"; + getNewType: (type: ChecklistType) => ChecklistType; handleBulkPaste: (itemsText: string) => void; isLoading: boolean; DeleteModal: () => JSX.Element; @@ -39,8 +39,8 @@ export const ChecklistModals = ({ const router = useRouter(); const t = useTranslations(); - const currentType = localList.type === "simple" ? t("checklists.simpleChecklist") : t("checklists.taskProject"); - const newType = getNewType(localList.type) === "simple" ? t("checklists.simpleChecklist") : t("checklists.taskProject"); + const currentType = localList.type === "simple" ? t("checklists.simpleChecklist") : t("checklists.kanbanBoard"); + const newType = getNewType(localList.type) === "simple" ? t("checklists.simpleChecklist") : t("checklists.kanbanBoard"); return ( <> diff --git a/app/_components/FeatureComponents/Home/Parts/ChecklistHome.tsx b/app/_components/FeatureComponents/Home/Parts/ChecklistHome.tsx index 9f9b9f26..35aa93d8 100644 --- a/app/_components/FeatureComponents/Home/Parts/ChecklistHome.tsx +++ b/app/_components/FeatureComponents/Home/Parts/ChecklistHome.tsx @@ -119,14 +119,14 @@ export const ChecklistHome = ({ const filteredTaskLists = useMemo(() => { if (!selectedCategory) return taskLists; return displayLists - .filter((list) => list.type === "task") + .filter((list) => list.type === "kanban" || list.type === "task") .filter((list) => !pinned.some((p) => p.id === list.id)); }, [taskLists, displayLists, selectedCategory, pinned]); const filteredSimpleLists = useMemo(() => { if (!selectedCategory) return simpleLists; return displayLists - .filter((list) => list.type !== "task") + .filter((list) => list.type !== "kanban" && list.type !== "task") .filter((list) => !pinned.some((p) => p.id === list.id)); }, [simpleLists, displayLists, selectedCategory, pinned]); @@ -325,18 +325,18 @@ export const ChecklistHome = ({

{selectedCategory - ? t("tasks.title") - : t("tasks.recentTasks")} + ? t("kanban.title") + : t("kanban.recentBoards")}

+

+ {monthLabel} +

+ + +
+ +
+ +
+
+ {weekdays.map((day) => ( +
+ {day} +
+ ))} +
+ + {calendarGrid.map((week, weekIndex) => ( +
+ {week.map((day, dayIndex) => { + if (!day) { + return ( +
+ ); + } + + const dateStr = _toLocalDate(day); + const dayEvents = getEventsForDate(day); + const isToday = dateStr === today; + + return ( +
+
+ {day.getDate()} +
+
+ {dayEvents.slice(0, 3).map((event) => ( +
{ + const item = checklist.items.find((i) => i.id === event.itemId); + if (item && onItemClick) onItemClick(item); + }} + className={cn( + "text-[10px] px-1 py-0.5 rounded truncate cursor-pointer hover:opacity-80 transition-opacity", + event.completed + ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 line-through" + : event.priority + ? getPriorityColor(event.priority as any) + : "bg-primary/10 text-primary" + )} + > + {event.title} +
+ ))} + {dayEvents.length > 3 && ( +
+ {t("kanban.moreEvents", { count: dayEvents.length - 3 })} +
+ )} +
+
+ ); + })} +
+ ))} +
+ + {unscheduledItems.length > 0 && ( +
+

+ + {t("kanban.unscheduled", { count: unscheduledItems.length })} +

+
+ {unscheduledItems.map((item) => ( +
onItemClick?.(item)} + className="text-xs px-2 py-1 rounded-full border border-border bg-muted/30 text-foreground cursor-pointer hover:bg-muted/50 transition-colors" + > + {item.text} +
+ ))} +
+
+ )} +
+ ); +}; diff --git a/app/_components/FeatureComponents/Checklists/Parts/Kanban/KanbanBoard.tsx b/app/_components/FeatureComponents/Kanban/Kanban.tsx similarity index 73% rename from app/_components/FeatureComponents/Checklists/Parts/Kanban/KanbanBoard.tsx rename to app/_components/FeatureComponents/Kanban/Kanban.tsx index b75a367d..7073ff8c 100644 --- a/app/_components/FeatureComponents/Checklists/Parts/Kanban/KanbanBoard.tsx +++ b/app/_components/FeatureComponents/Kanban/Kanban.tsx @@ -6,66 +6,47 @@ import { DragOverlay, PointerSensor, KeyboardSensor, + TouchSensor, useSensor, useSensors, + closestCorners, } from "@dnd-kit/core"; import { Checklist, KanbanStatus } from "@/app/_types"; import { KanbanColumn } from "./KanbanColumn"; -import { KanbanItem } from "./KanbanItem"; -import { ChecklistHeading } from "../Common/ChecklistHeading"; +import { KanbanCard } from "./KanbanCard"; +import { ChecklistHeading } from "../Checklists/Parts/Common/ChecklistHeading"; import { BulkPasteModal } from "@/app/_components/GlobalComponents/Modals/BulkPasteModal/BulkPasteModal"; -import { StatusManagementModal } from "./StatusManagementModal"; -import { ArchivedItemsModal } from "./ArchivedItemsModal"; -import { useKanbanBoard } from "../../../../../_hooks/useKanbanBoard"; +import { StatusManager } from "./StatusManager"; +import { ArchivedItems } from "./ArchivedItems"; +import { useKanbanBoard } from "@/app/_hooks/kanban/useKanban"; +import { useKanbanReminders } from "@/app/_hooks/kanban/useKanbanReminders"; import { ItemTypes, TaskStatus, TaskStatusLabels } from "@/app/_types/enums"; -import { ReferencedBySection } from "../../../Notes/Parts/ReferencedBySection"; +import { ReferencedBySection } from "../Notes/Parts/ReferencedBySection"; import { getReferences } from "@/app/_utils/indexes-utils"; import { useAppMode } from "@/app/_providers/AppModeProvider"; import { encodeCategoryPath } from "@/app/_utils/global-utils"; import { usePermissions } from "@/app/_providers/PermissionsProvider"; -import { Settings01Icon, Archive02Icon } from "hugeicons-react"; +import { Settings01Icon, Archive02Icon, Calendar03Icon, TaskDaily01Icon } from "hugeicons-react"; +import { CalendarView } from "./CalendarView"; +import { KanbanCardDetail } from "./KanbanCardDetail"; import { Button } from "@/app/_components/GlobalComponents/Buttons/Button"; import { updateChecklistStatuses } from "@/app/_server/actions/checklist"; import { unarchiveItem } from "@/app/_server/actions/checklist-item"; import { useTranslations } from "next-intl"; +import { DEFAULT_KANBAN_STATUSES } from "@/app/_consts/kanban"; interface KanbanBoardProps { checklist: Checklist; onUpdate: (updatedChecklist: Checklist) => void; } -const defaultStatuses: KanbanStatus[] = [ - { - id: TaskStatus.TODO, - label: TaskStatusLabels.TODO, - order: 0, - autoComplete: false, - }, - { - id: TaskStatus.IN_PROGRESS, - label: TaskStatusLabels.IN_PROGRESS, - order: 1, - autoComplete: false, - }, - { - id: TaskStatus.COMPLETED, - label: TaskStatusLabels.COMPLETED, - order: 2, - autoComplete: true, - }, - { - id: TaskStatus.PAUSED, - label: TaskStatusLabels.PAUSED, - order: 3, - autoComplete: false, - }, -]; - -export const KanbanBoard = ({ checklist, onUpdate }: KanbanBoardProps) => { +export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => { const t = useTranslations(); const [isClient, setIsClient] = useState(false); const [showStatusModal, setShowStatusModal] = useState(false); const [showArchivedModal, setShowArchivedModal] = useState(false); + const [viewMode, setViewMode] = useState<"board" | "calendar">("board"); + const [calendarSelectedItem, setCalendarSelectedItem] = useState(null); const { linkIndex, notes, checklists, appSettings, allSharedItems } = useAppMode(); const encodedCategory = encodeCategoryPath( @@ -85,8 +66,10 @@ export const KanbanBoard = ({ checklist, onUpdate }: KanbanBoardProps) => { setShowBulkPasteModal, focusKey, refreshChecklist, + handleItemUpdate, getItemsByStatus, handleDragStart, + handleDragOver, handleDragEnd, handleAddItem, handleBulkPaste, @@ -94,8 +77,10 @@ export const KanbanBoard = ({ checklist, onUpdate }: KanbanBoardProps) => { activeItem, } = useKanbanBoard({ checklist, onUpdate }); + useKanbanReminders(localChecklist); + const statuses = useMemo(() => { - const currentStatuses = localChecklist.statuses || defaultStatuses; + const currentStatuses = localChecklist.statuses || DEFAULT_KANBAN_STATUSES; return currentStatuses.map(status => { if (status.id === TaskStatus.COMPLETED && status.autoComplete === undefined) { return { ...status, autoComplete: true }; @@ -159,6 +144,12 @@ export const KanbanBoard = ({ checklist, onUpdate }: KanbanBoardProps) => { tolerance: 5, }, }), + useSensor(TouchSensor, { + activationConstraint: { + delay: 250, + tolerance: 5, + }, + }), useSensor(KeyboardSensor) ); @@ -196,11 +187,31 @@ export const KanbanBoard = ({ checklist, onUpdate }: KanbanBoardProps) => { autoFocus={true} focusKey={focusKey} placeholder={t("checklists.addNewTask")} - submitButtonText={t("tasks.addTask")} + submitButtonText={t("kanban.addItem")} /> )}
- {permissions?.canEdit && ( +
+ + +
+ {permissions?.canEdit && viewMode === "board" && ( + )} + {viewMode === "board" && ( + )} -
- {isClient ? ( + {viewMode === "calendar" ? ( +
+ setCalendarSelectedItem(item)} + /> +
+ ) : isClient ? (
{
4 - ? "flex-shrink-0 min-w-[20%]" - : "min-w-[24%] " + ? "flex-shrink-0 min-w-[20%]" + : "min-w-[24%] " }`} > { status={column.status} checklistId={localChecklist.id} category={localChecklist.category || "Uncategorized"} - onUpdate={refreshChecklist} + onUpdate={handleItemUpdate} isShared={isShared} statusColor={ statuses.find((s) => s.id === column.id)?.color @@ -267,7 +289,7 @@ export const KanbanBoard = ({ checklist, onUpdate }: KanbanBoardProps) => { {activeItem ? ( - { status={column.status} checklistId={localChecklist.id} category={localChecklist.category || "Uncategorized"} - onUpdate={refreshChecklist} + onUpdate={handleItemUpdate} isShared={isShared} statusColor={ statuses.find((s) => s.id === column.id)?.color @@ -325,6 +347,19 @@ export const KanbanBoard = ({ checklist, onUpdate }: KanbanBoardProps) => {
+ {calendarSelectedItem && ( + setCalendarSelectedItem(null)} + onUpdate={handleItemUpdate} + checklistId={localChecklist.id} + category={localChecklist.category || "Uncategorized"} + isShared={isShared} + /> + )} + {showBulkPasteModal && ( { )} {showStatusModal && ( - setShowStatusModal(false)} currentStatuses={statuses} @@ -345,7 +380,7 @@ export const KanbanBoard = ({ checklist, onUpdate }: KanbanBoardProps) => { )} {showArchivedModal && ( - setShowArchivedModal(false)} archivedItems={archivedItems} diff --git a/app/_components/FeatureComponents/Checklists/Parts/Kanban/KanbanItem.tsx b/app/_components/FeatureComponents/Kanban/KanbanCard.tsx similarity index 70% rename from app/_components/FeatureComponents/Checklists/Parts/Kanban/KanbanItem.tsx rename to app/_components/FeatureComponents/Kanban/KanbanCard.tsx index c069895e..3afbb7c0 100644 --- a/app/_components/FeatureComponents/Checklists/Parts/Kanban/KanbanItem.tsx +++ b/app/_components/FeatureComponents/Kanban/KanbanCard.tsx @@ -6,24 +6,28 @@ import { Item, Checklist, KanbanStatus } from "@/app/_types"; import { cn } from "@/app/_utils/global-utils"; import { Dropdown } from "@/app/_components/GlobalComponents/Dropdowns/Dropdown"; import { useState, useEffect, memo, useMemo, useCallback } from "react"; -import { TaskStatus, TaskStatusLabels } from "@/app/_types/enums"; -import { SubtaskModal } from "./SubtaskModal"; +import { TaskStatus } from "@/app/_types/enums"; +import { KanbanCardDetail } from "./KanbanCardDetail"; import { useAppMode } from "@/app/_providers/AppModeProvider"; -import { useKanbanItem } from "@/app/_hooks/useKanbanItem"; +import { useKanbanItem } from "@/app/_hooks/kanban/useKanbanItem"; import { formatTimerTime, getStatusColor, getStatusIcon, -} from "@/app/_utils/kanban-utils"; + getPriorityColor, + getPriorityLabel, +} from "@/app/_utils/kanban/index"; import { TimeEntriesAccordion } from "./TimeEntriesAccordion"; import { KanbanItemTimer } from "./KanbanItemTimer"; import { KanbanItemContent } from "./KanbanItemContent"; import { getRecurrenceDescription } from "@/app/_utils/recurrence-utils"; import { usePermissions } from "@/app/_providers/PermissionsProvider"; -import { CircleIcon } from "hugeicons-react"; +import { formatReminderTime } from "@/app/_utils/kanban/reminder-utils"; +import { CircleIcon, Notification03Icon, UserIcon } from "hugeicons-react"; import { usePreferredDateTime } from "@/app/_hooks/usePreferredDateTime"; +import { useTranslations } from "next-intl"; -interface KanbanItemProps { +interface KanbanCardProps { checklist: Checklist; item: Item; isDragging?: boolean; @@ -34,7 +38,7 @@ interface KanbanItemProps { statuses: KanbanStatus[]; } -const KanbanItemComponent = ({ +const KanbanCardComponent = ({ checklist, item, isDragging, @@ -43,7 +47,8 @@ const KanbanItemComponent = ({ onUpdate, isShared, statuses, -}: KanbanItemProps) => { +}: KanbanCardProps) => { + const t = useTranslations(); const { usersPublicData } = useAppMode(); const { permissions } = usePermissions(); const { formatDateString, formatDateTimeString, formatTimeString } = @@ -52,17 +57,16 @@ const KanbanItemComponent = ({ const getUserAvatarUrl = useCallback( (username: string) => { if (!usersPublicData) return ""; - return ( usersPublicData.find( - (user) => user.username?.toLowerCase() === username?.toLowerCase() + (user) => user.username?.toLowerCase() === username?.toLowerCase(), )?.avatarUrl || "" ); }, - [usersPublicData] + [usersPublicData], ); - const [showSubtaskModal, setShowSubtaskModal] = useState(false); + const [showDetailModal, setShowDetailModal] = useState(false); const kanbanItemHook = useKanbanItem({ checklist, @@ -108,13 +112,13 @@ const KanbanItemComponent = ({ return ( <> - {showSubtaskModal && ( - setShowSubtaskModal(false)} + isOpen={showDetailModal} + onClose={() => setShowDetailModal(false)} onUpdate={onUpdate} checklistId={checklistId} category={category} @@ -127,12 +131,12 @@ const KanbanItemComponent = ({ style={style} {...attributes} {...listeners} - onDoubleClick={() => setShowSubtaskModal(true)} + onDoubleClick={() => setShowDetailModal(true)} className={cn( "group bg-background border rounded-jotty p-3 transition-all duration-200 hover:shadow-md cursor-grab active:cursor-grabbing", getStatusColor(item.status), (isDragging || isSortableDragging) && - "opacity-50 scale-95 rotate-[4deg] shadow-lg z-50 transition-all duration-200" + "opacity-50 scale-95 rotate-[4deg] shadow-lg z-50 transition-all duration-200", )} >
@@ -148,7 +152,7 @@ const KanbanItemComponent = ({ onEditTextChange={kanbanItemHook.setEditText} onEditSave={kanbanItemHook.handleSave} onEditKeyDown={kanbanItemHook.handleKeyDown} - onShowSubtaskModal={() => setShowSubtaskModal(true)} + onShowSubtaskModal={() => setShowDetailModal(true)} onEdit={kanbanItemHook.handleEdit} onDelete={kanbanItemHook.handleDelete} onArchive={kanbanItemHook.handleArchive} @@ -156,6 +160,39 @@ const KanbanItemComponent = ({ formatDateTimeString={formatDateTimeString} /> +
+ {item.priority && item.priority !== "none" && ( + + {getPriorityLabel(item.priority, t)} + + )} + + {item.score !== undefined && ( + + {t("kanban.scoreLabel", { score: item.score })} + + )} + + {item.assignee && ( + + + {item.assignee} + + )} + + {item.reminder && !item.reminder.notified && ( + + + {formatReminderTime(item.reminder.datetime)} + + )} +
+ { - return text.replace(/\n/g, "\\n"); +const _sanitizeDescription = (text: string): string => + text.replace(/\n/g, "\\n"); + +const _unsanitizeDescription = (text: string): string => + text.replace(/\\n/g, "\n"); + +const _toLocalDateTimeValue = (isoStr: string): string => { + if (!isoStr) return ""; + const d = new Date(isoStr); + if (isNaN(d.getTime())) return ""; + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; }; -const unsanitizeDescription = (text: string): string => { - return text.replace(/\\n/g, "\n"); +const _toLocalDateValue = (isoStr: string): string => { + if (!isoStr) return ""; + const d = new Date(isoStr); + if (isNaN(d.getTime())) return ""; + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; }; -export const SubtaskModal = ({ +export const KanbanCardDetail = ({ checklist, item: initialItem, isOpen, @@ -49,38 +71,92 @@ export const SubtaskModal = ({ checklistId, category, isShared, -}: SubtaskModalProps) => { +}: KanbanCardDetailProps) => { const t = useTranslations(); const { permissions } = usePermissions(); const { formatDateTimeString } = usePreferredDateTime(); - + console.log(initialItem); const [item, setItem] = useState(initialItem); const [isEditing, setIsEditing] = useState(false); - const [editText, setEditText] = useState(item.text); + const [editText, setEditText] = useState(initialItem.text); const [editDescription, setEditDescription] = useState( - unsanitizeDescription(item.description || "") + _unsanitizeDescription(initialItem.description || ""), ); const [newSubtaskText, setNewSubtaskText] = useState(""); + const [scoreInput, setScoreInput] = useState( + initialItem.score?.toString() || "", + ); + const [reminderInput, setReminderInput] = useState( + initialItem.reminder?.datetime || "", + ); + const [targetDateInput, setTargetDateInput] = useState( + initialItem.targetDate || "", + ); + const [priorityInput, setPriorityInput] = useState( + initialItem.priority || KanbanPriorityLevel.NONE, + ); + const [assigneeInput, setAssigneeInput] = useState( + initialItem.assignee || "", + ); + const [availableUsers, setAvailableUsers] = useState< + { username: string; avatarUrl?: string }[] + >([]); useEffect(() => { setItem(initialItem); setEditText(initialItem.text); - setEditDescription(unsanitizeDescription(initialItem.description || "")); + setEditDescription(_unsanitizeDescription(initialItem.description || "")); + setScoreInput(initialItem.score?.toString() || ""); + setReminderInput(initialItem.reminder?.datetime || ""); + setTargetDateInput(initialItem.targetDate || ""); + setPriorityInput(initialItem.priority || KanbanPriorityLevel.NONE); + setAssigneeInput(initialItem.assignee || ""); }, [initialItem]); + useEffect(() => { + if (!isOpen) return; + const _loadUsers = async () => { + const allUsers = await getUsers(); + const sharedWithUsers = await getUsersWithAccess( + checklistId, + checklist.uuid, + ); + const userMap = new Map< + string, + { username: string; avatarUrl?: string } + >(); + allUsers.forEach((u: { username: string; avatarUrl?: string }) => { + userMap.set(u.username, { + username: u.username, + avatarUrl: u.avatarUrl, + }); + }); + sharedWithUsers.forEach((username: string) => { + if (!userMap.has(username)) { + userMap.set(username, { username }); + } + }); + if (checklist.owner && !userMap.has(checklist.owner)) { + userMap.set(checklist.owner, { username: checklist.owner }); + } + setAvailableUsers(Array.from(userMap.values())); + }; + _loadUsers(); + }, [isOpen, checklistId, checklist.uuid, checklist.owner]); + const descriptionHtml = useMemo(() => { const noDescText = `

${t( - "checklists.noDescription" + "checklists.noDescription", )}

`; if (!item.description) return noDescText; - const unsanitized = unsanitizeDescription(item.description); + const unsanitized = _unsanitizeDescription(item.description); const withLineBreaks = unsanitized.replace(/\n/g, " \n"); return convertMarkdownToHtml(withLineBreaks) || noDescText; }, [item.description, t]); - const findItemInChecklist = ( + const _findItemInChecklist = ( checklist: Checklist, - itemId: string + itemId: string, ): Item | null => { const searchItems = (items: Item[]): Item | null => { for (const item of items) { @@ -95,9 +171,25 @@ export const SubtaskModal = ({ return searchItems(checklist.items); }; + const _saveField = async (fields: Record) => { + const formData = new FormData(); + formData.append("listId", checklistId); + formData.append("itemId", item.id); + formData.append("category", category); + Object.entries(fields).forEach(([key, value]) => { + formData.append(key, value); + }); + const result = await updateItem(checklist, formData); + if (result.success && result.data) { + onUpdate(result.data); + const updatedItem = _findItemInChecklist(result.data, item.id); + if (updatedItem) setItem(updatedItem); + } + }; + const handleSave = async () => { - const sanitizedDescription = sanitizeDescription(editDescription.trim()); - const currentUnsanitized = unsanitizeDescription(item.description || ""); + const sanitizedDescription = _sanitizeDescription(editDescription.trim()); + const currentUnsanitized = _unsanitizeDescription(item.description || ""); if ( editText.trim() !== item.text || @@ -113,12 +205,12 @@ export const SubtaskModal = ({ const result = await updateItem(checklist, formData); if (result.success && result.data) { onUpdate(result.data); - const updatedItem = findItemInChecklist(result.data, item.id); + const updatedItem = _findItemInChecklist(result.data, item.id); if (updatedItem) { setItem(updatedItem); setEditText(updatedItem.text); setEditDescription( - unsanitizeDescription(updatedItem.description || "") + _unsanitizeDescription(updatedItem.description || ""), ); } } @@ -138,10 +230,8 @@ export const SubtaskModal = ({ const result = await createSubItem(formData); if (result.success && result.data) { onUpdate(result.data); - const updatedItem = findItemInChecklist(result.data, item.id); - if (updatedItem) { - setItem(updatedItem); - } + const updatedItem = _findItemInChecklist(result.data, item.id); + if (updatedItem) setItem(updatedItem); setNewSubtaskText(""); } }; @@ -156,10 +246,8 @@ export const SubtaskModal = ({ const result = await createSubItem(formData); if (result.success && result.data) { onUpdate(result.data); - const updatedItem = findItemInChecklist(result.data, item.id); - if (updatedItem) { - setItem(updatedItem); - } + const updatedItem = _findItemInChecklist(result.data, item.id); + if (updatedItem) setItem(updatedItem); } }; @@ -173,10 +261,8 @@ export const SubtaskModal = ({ const result = await updateItem(checklist, formData); if (result.success && result.data) { onUpdate(result.data); - const updatedItem = findItemInChecklist(result.data, item.id); - if (updatedItem) { - setItem(updatedItem); - } + const updatedItem = _findItemInChecklist(result.data, item.id); + if (updatedItem) setItem(updatedItem); } }; @@ -190,10 +276,8 @@ export const SubtaskModal = ({ const result = await updateItem(checklist, formData); if (result.success && result.data) { onUpdate(result.data); - const updatedItem = findItemInChecklist(result.data, item.id); - if (updatedItem) { - setItem(updatedItem); - } + const updatedItem = _findItemInChecklist(result.data, item.id); + if (updatedItem) setItem(updatedItem); } }; @@ -206,34 +290,27 @@ export const SubtaskModal = ({ const result = await deleteItem(formData); if (result.success && result.data) { onUpdate(result.data); - const updatedItem = findItemInChecklist(result.data, item.id); - if (updatedItem) { - setItem(updatedItem); - } + const updatedItem = _findItemInChecklist(result.data, item.id); + if (updatedItem) setItem(updatedItem); } }; const handleToggleAll = async (completed: boolean) => { if (!item.children?.length) return; - const findTargetItems = (items: Item[]): Item[] => { + const _findTargetItems = (items: Item[]): Item[] => { const targets: Item[] = []; - items.forEach((subtask) => { const shouldToggle = completed ? !subtask.completed : subtask.completed; - if (shouldToggle) { - targets.push(subtask); - } - + if (shouldToggle) targets.push(subtask); if (subtask.children && subtask.children.length > 0) { - targets.push(...findTargetItems(subtask.children)); + targets.push(..._findTargetItems(subtask.children)); } }); - return targets; }; - const targetItems = findTargetItems(item.children); + const targetItems = _findTargetItems(item.children); if (targetItems.length === 0) return; const formData = new FormData(); @@ -245,13 +322,78 @@ export const SubtaskModal = ({ const result = await bulkToggleItems(formData); if (result.success && result.data) { onUpdate(result.data); - const updatedItem = findItemInChecklist(result.data, item.id); - if (updatedItem) { - setItem(updatedItem); - } + const updatedItem = _findItemInChecklist(result.data, item.id); + if (updatedItem) setItem(updatedItem); } }; + const handlePriorityChange = async (priority: KanbanPriority) => { + setPriorityInput(priority); + await _saveField({ priority }); + }; + + const handleScoreSave = async () => { + const score = parseInt(scoreInput); + if (isNaN(score)) return; + await _saveField({ score: score.toString() }); + }; + + const handleAssigneeChange = async (username: string) => { + setAssigneeInput(username); + await _saveField({ assignee: username }); + }; + + const handleReminderSave = async () => { + await _saveField({ + reminder: reminderInput + ? JSON.stringify({ datetime: new Date(reminderInput).toISOString() }) + : "", + }); + }; + + const handleTargetDateChange = async (value: string) => { + setTargetDateInput(value); + await _saveField({ + targetDate: value ? new Date(value).toISOString() : "", + }); + }; + + const priorities: KanbanPriority[] = [ + KanbanPriorityLevel.CRITICAL, + KanbanPriorityLevel.HIGH, + KanbanPriorityLevel.MEDIUM, + KanbanPriorityLevel.LOW, + KanbanPriorityLevel.NONE, + ]; + + const assigneeOptions = useMemo( + () => [ + { + id: "", + name: ( + + + {t("kanban.unassigned")} + + ), + }, + ...availableUsers.map((user) => ({ + id: user.username, + name: ( + + + {user.username} + + ), + })), + ], + [availableUsers, t], + ); + const renderMetadata = () => { const metadata = []; @@ -260,7 +402,7 @@ export const SubtaskModal = ({ t("common.createdByOn", { user: item.createdBy, date: formatDateTimeString(item.createdAt!), - }) + }), ); } @@ -269,7 +411,7 @@ export const SubtaskModal = ({ t("common.lastModifiedByOn", { user: item.lastModifiedBy, date: formatDateTimeString(item.lastModifiedAt!), - }) + }), ); } @@ -289,7 +431,7 @@ export const SubtaskModal = ({ key={i} className="text-md lg:text-xs text-muted-foreground flex items-start gap-2" > - + {text}

))} @@ -311,7 +453,7 @@ export const SubtaskModal = ({
-

- Press{" "} - - Enter - {" "} - to save title, - - Ctrl+Enter - {" "} - to save description, - - Esc - {" "} - to cancel -

+ ))} +
+
+ +
+ + setScoreInput(e.target.value)} + onBlur={handleScoreSave} + onKeyDown={(e) => e.key === "Enter" && handleScoreSave()} + className="w-20 px-2 py-1 text-sm bg-background border border-input rounded-jotty focus:outline-none focus:border-ring" + placeholder="0" + /> +
+ +
+ + handleAssigneeChange(value)} + placeholder={t("kanban.unassigned")} + /> +
+
+ +
+
+ + setReminderInput(e.target.value)} + onBlur={handleReminderSave} + className="w-full px-2 py-1.5 text-sm bg-background border border-input rounded-jotty focus:outline-none focus:border-ring" + /> +
+ +
+ + handleTargetDateChange(e.target.value)} + className="w-full px-2 py-1.5 text-sm bg-background border border-input rounded-jotty focus:outline-none focus:border-ring" + /> +
+
+
+ )} + {!isEditing && (
diff --git a/app/_components/FeatureComponents/Checklists/Parts/Kanban/KanbanColumn.tsx b/app/_components/FeatureComponents/Kanban/KanbanColumn.tsx similarity index 94% rename from app/_components/FeatureComponents/Checklists/Parts/Kanban/KanbanColumn.tsx rename to app/_components/FeatureComponents/Kanban/KanbanColumn.tsx index 684725b4..186a19eb 100644 --- a/app/_components/FeatureComponents/Checklists/Parts/Kanban/KanbanColumn.tsx +++ b/app/_components/FeatureComponents/Kanban/KanbanColumn.tsx @@ -7,7 +7,7 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { Item, Checklist, KanbanStatus } from "@/app/_types"; -import { KanbanItem } from "./KanbanItem"; +import { KanbanCard } from "./KanbanCard"; import { cn } from "@/app/_utils/global-utils"; import { TaskStatus } from "@/app/_types/enums"; import { useTranslations } from "next-intl"; @@ -57,7 +57,7 @@ const KanbanColumnComponent = ({ const color = statusColor || defaultColors[status] || "#6b7280"; const { borderColor, bgColor } = useMemo(() => { - const hexToRgb = (hex: string) => { + const _hexToRgb = (hex: string) => { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); return result ? { @@ -68,7 +68,7 @@ const KanbanColumnComponent = ({ : null; }; - const rgb = hexToRgb(color); + const rgb = _hexToRgb(color); return { borderColor: rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.3)` : color, bgColor: rgb ? `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.05)` : color, @@ -93,7 +93,7 @@ const KanbanColumnComponent = ({
{items.map((item) => ( - { + const t = useTranslations(); + const router = useRouter(); + const { openCreateChecklistModal } = useShortcut(); + const { isInitialized } = useAppMode(); + const { viewMode } = useSettings(); + + const { + taskFilter, + selectedCategories, + recursive, + itemsPerPage, + setItemsPerPage, + setPaginationInfo, + } = useTasksFilter(); + + const filteredLists = useMemo(() => { + let filtered = [...initialLists]; + + if (taskFilter === "pinned") { + const pinnedPaths = user?.pinnedLists || []; + filtered = filtered.filter((list) => { + const uuidPath = `${list.category || "Uncategorized"}/${list.uuid || list.id}`; + const idPath = `${list.category || "Uncategorized"}/${list.id}`; + return pinnedPaths.includes(uuidPath) || pinnedPaths.includes(idPath); + }); + } else if (taskFilter === "completed") { + filtered = filtered.filter( + (list) => + list.items.length > 0 && + list.items.every((item) => isItemCompleted(item, list.type)) + ); + } else if (taskFilter === "incomplete") { + filtered = filtered.filter( + (list) => + list.items.length === 0 || + !list.items.every((item) => isItemCompleted(item, list.type)) + ); + } else if (taskFilter === "todo") { + filtered = filtered.filter((list) => + list.items.some((item) => item.status === TaskStatus.TODO) + ); + } else if (taskFilter === "in-progress") { + filtered = filtered.filter((list) => + list.items.some((item) => item.status === TaskStatus.IN_PROGRESS) + ); + } + + if (selectedCategories.length > 0) { + filtered = filtered.filter((list) => { + const listCategory = list.category || "Uncategorized"; + if (recursive) { + return selectedCategories.some( + (selected) => + listCategory === selected || + listCategory.startsWith(selected + "/") + ); + } + return selectedCategories.includes(listCategory); + }); + } + + return filtered.sort( + (a, b) => + new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() + ); + }, [initialLists, taskFilter, selectedCategories, recursive, user?.pinnedLists]); + + const { + currentPage, + totalPages, + paginatedItems, + goToPage, + totalItems, + handleItemsPerPageChange, + } = usePagination({ + items: filteredLists, + itemsPerPage, + onItemsPerPageChange: setItemsPerPage, + }); + + useEffect(() => { + setPaginationInfo({ + currentPage, + totalPages, + totalItems, + onPageChange: goToPage, + onItemsPerPageChange: handleItemsPerPageChange, + }); + }, [currentPage, totalPages, totalItems, goToPage, handleItemsPerPageChange, setPaginationInfo]); + + const stats = useMemo(() => { + const allItems = initialLists.flatMap((list) => + list.items.filter((item) => !item.isArchived) + ); + const totalBoards = initialLists.length; + const totalItemCount = allItems.length; + const completedCount = allItems.filter( + (item) => item.status === TaskStatus.COMPLETED || item.completed + ).length; + const completionRate = + totalItemCount > 0 ? Math.round((completedCount / totalItemCount) * 100) : 0; + const todoCount = allItems.filter((item) => item.status === TaskStatus.TODO).length; + const inProgressCount = allItems.filter( + (item) => item.status === TaskStatus.IN_PROGRESS + ).length; + + return { + totalBoards, + completedCount, + totalItemCount, + completionRate, + todoCount, + inProgressCount, + }; + }, [initialLists]); + + if (!isInitialized) { + return ( +
+
+ +
+
+ ); + } + + if (initialLists.length === 0) { + return ( + + } + title={t('kanban.noBoardsYet')} + description={t('kanban.createFirstBoard')} + buttonText={t('kanban.newBoard')} + onButtonClick={() => openCreateChecklistModal()} + /> + ); + } + + const renderList = (list: Checklist) => { + const categoryPath = `${list.category || "Uncategorized"}/${list.id}`; + const isPinned = user?.pinnedLists?.includes(categoryPath); + + if (viewMode === 'list') { + return ( + router.push(`/checklist/${categoryPath}`)} + isPinned={isPinned} + onTogglePin={() => {}} + /> + ); + } + + if (viewMode === 'grid') { + return ( + router.push(`/checklist/${categoryPath}`)} + isPinned={isPinned} + onTogglePin={() => {}} + /> + ); + } + + return ( + router.push(`/checklist/${categoryPath}`)} + isPinned={isPinned} + onTogglePin={() => {}} + /> + ); + }; + + return ( + <> +
+
+
+
+ +
+
+
+ {stats.totalBoards} +
+
{t("kanban.boards")}
+
+
+ +
+
+ +
+
+
+ {stats.completedCount} +
+
{t("kanban.completed")}
+
+
+ +
+
+ +
+
+
+ {stats.completionRate}% +
+
{t("checklists.progress")}
+
+
+ +
+
+ +
+
+
+ {stats.todoCount} +
+
{t("kanban.todo")}
+
+
+ +
+
+ +
+
+
+ {stats.inProgressCount} +
+
{t("kanban.inProgress")}
+
+
+
+
+ + {paginatedItems.length === 0 ? ( +
+ +

+ {t("kanban.noBoards")} +

+

+ {t("kanban.tryAdjustingFilters")} +

+
+ ) : ( +
+ {viewMode === 'card' && ( +
+ {paginatedItems.map(renderList)} +
+ )} + {viewMode === 'list' && ( +
+ {paginatedItems.map(renderList)} +
+ )} + {viewMode === 'grid' && ( +
+ {paginatedItems.map(renderList)} +
+ )} +
+ )} + + ); +}; diff --git a/app/_components/FeatureComponents/Checklists/Parts/Kanban/StatusManagementModal.tsx b/app/_components/FeatureComponents/Kanban/StatusManagementModal.tsx similarity index 100% rename from app/_components/FeatureComponents/Checklists/Parts/Kanban/StatusManagementModal.tsx rename to app/_components/FeatureComponents/Kanban/StatusManagementModal.tsx diff --git a/app/_components/FeatureComponents/Kanban/StatusManager.tsx b/app/_components/FeatureComponents/Kanban/StatusManager.tsx new file mode 100644 index 00000000..20adadb7 --- /dev/null +++ b/app/_components/FeatureComponents/Kanban/StatusManager.tsx @@ -0,0 +1,3 @@ +"use client"; + +export { StatusManagementModal as StatusManager } from "./StatusManagementModal"; diff --git a/app/_components/FeatureComponents/Checklists/Parts/Kanban/TimeEntriesAccordion.tsx b/app/_components/FeatureComponents/Kanban/TimeEntriesAccordion.tsx similarity index 100% rename from app/_components/FeatureComponents/Checklists/Parts/Kanban/TimeEntriesAccordion.tsx rename to app/_components/FeatureComponents/Kanban/TimeEntriesAccordion.tsx diff --git a/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/InternalLinkComponent.tsx b/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/InternalLinkComponent.tsx index 3c863baf..3ff658a6 100644 --- a/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/InternalLinkComponent.tsx +++ b/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/InternalLinkComponent.tsx @@ -285,7 +285,7 @@ export const InternalLinkComponent = ({ {fullItem && "type" in fullItem && fullItem.type ? ( <> - {fullItem.type === "task" ? ( + {(fullItem.type === "kanban" || fullItem.type === "task") ? ( ) : ( diff --git a/app/_components/FeatureComponents/PublicView/Parts/PublicChecklistBody.tsx b/app/_components/FeatureComponents/PublicView/Parts/PublicChecklistBody.tsx index 8458da86..0d3d15d9 100644 --- a/app/_components/FeatureComponents/PublicView/Parts/PublicChecklistBody.tsx +++ b/app/_components/FeatureComponents/PublicView/Parts/PublicChecklistBody.tsx @@ -21,7 +21,7 @@ export const PublicChecklistBody = ({ }, [checklist.items]); const taskItemsByStatus = useMemo(() => { - if (checklist.type !== "task") return null; + if (checklist.type !== "kanban" && checklist.type !== "task") return null; const initialAcc: Record = { todo: [], in_progress: [], @@ -47,7 +47,7 @@ export const PublicChecklistBody = ({ ); } - if (checklist.type === "task" && taskItemsByStatus) { + if ((checklist.type === "kanban" || checklist.type === "task") && taskItemsByStatus) { return Object.entries(taskItemsByStatus).map(([status, items]) => ( )); diff --git a/app/_components/FeatureComponents/PublicView/Parts/PublicChecklistHeader.tsx b/app/_components/FeatureComponents/PublicView/Parts/PublicChecklistHeader.tsx index 76715675..146bfd0c 100644 --- a/app/_components/FeatureComponents/PublicView/Parts/PublicChecklistHeader.tsx +++ b/app/_components/FeatureComponents/PublicView/Parts/PublicChecklistHeader.tsx @@ -23,7 +23,7 @@ export const PublicChecklistHeader = ({ }: PublicChecklistHeaderProps) => (
- {checklist.type === "task" ? ( + {(checklist.type === "kanban" || checklist.type === "task") ? ( ) : ( diff --git a/app/_components/FeatureComponents/Sidebar/Parts/SidebarItem.tsx b/app/_components/FeatureComponents/Sidebar/Parts/SidebarItem.tsx index ea959380..292b7dba 100644 --- a/app/_components/FeatureComponents/Sidebar/Parts/SidebarItem.tsx +++ b/app/_components/FeatureComponents/Sidebar/Parts/SidebarItem.tsx @@ -221,7 +221,7 @@ export const SidebarItem = ({ /> ) : ( <> - {"type" in item && item.type === "task" ? ( + {"type" in item && (item.type === "kanban" || item.type === "task") ? (
- {list.type === "task" && } + {(list.type === "kanban" || list.type === "task") && }
diff --git a/app/_components/GlobalComponents/Cards/ChecklistGridItem.tsx b/app/_components/GlobalComponents/Cards/ChecklistGridItem.tsx index 73349dd7..5003d0ad 100644 --- a/app/_components/GlobalComponents/Cards/ChecklistGridItem.tsx +++ b/app/_components/GlobalComponents/Cards/ChecklistGridItem.tsx @@ -65,7 +65,7 @@ export const ChecklistGridItem = ({ return list?.category ? list?.category.split("/").pop() : null; }, [list?.category]); - const isTask = list.type === "task"; + const isTask = list.type === "kanban" || list.type === "task"; const style = isDragging ? { opacity: 0.4 } diff --git a/app/_components/GlobalComponents/Cards/ChecklistListItem.tsx b/app/_components/GlobalComponents/Cards/ChecklistListItem.tsx index 7b1479bd..3628082a 100644 --- a/app/_components/GlobalComponents/Cards/ChecklistListItem.tsx +++ b/app/_components/GlobalComponents/Cards/ChecklistListItem.tsx @@ -65,7 +65,7 @@ export const ChecklistListItem = ({ return list?.category ? list?.category.split("/").pop() : null; }, [list?.category]); - const isTask = list.type === "task"; + const isTask = list.type === "kanban" || list.type === "task"; const style = isDragging ? { opacity: 0.4 } diff --git a/app/_consts/kanban.ts b/app/_consts/kanban.ts new file mode 100644 index 00000000..584810e2 --- /dev/null +++ b/app/_consts/kanban.ts @@ -0,0 +1,29 @@ +import { KanbanStatus } from "@/app/_types"; +import { TaskStatus, TaskStatusLabels } from "@/app/_types/enums"; + +export const DEFAULT_KANBAN_STATUSES: KanbanStatus[] = [ + { + id: TaskStatus.TODO, + label: TaskStatusLabels.TODO, + order: 0, + autoComplete: false, + }, + { + id: TaskStatus.IN_PROGRESS, + label: TaskStatusLabels.IN_PROGRESS, + order: 1, + autoComplete: false, + }, + { + id: TaskStatus.COMPLETED, + label: TaskStatusLabels.COMPLETED, + order: 2, + autoComplete: true, + }, + { + id: TaskStatus.PAUSED, + label: TaskStatusLabels.PAUSED, + order: 3, + autoComplete: false, + }, +]; diff --git a/app/_hooks/kanban/index.tsx b/app/_hooks/kanban/index.tsx new file mode 100644 index 00000000..f2de4f93 --- /dev/null +++ b/app/_hooks/kanban/index.tsx @@ -0,0 +1,4 @@ +export { useKanbanBoard } from "./useKanban"; +export { useKanbanItem } from "./useKanbanItem"; +export { useCalendarView } from "./useCalendarView"; +export { useKanbanReminders } from "./useKanbanReminders"; diff --git a/app/_hooks/kanban/useCalendarView.ts b/app/_hooks/kanban/useCalendarView.ts new file mode 100644 index 00000000..79d04195 --- /dev/null +++ b/app/_hooks/kanban/useCalendarView.ts @@ -0,0 +1,91 @@ +"use client"; + +import { useState, useMemo, useCallback } from "react"; +import { Item, Checklist } from "@/app/_types"; +import { + parseItemsForCalendar, + generateICS, + getCalendarGrid, + getItemsGroupedByDate, + CalendarEvent, +} from "@/app/_utils/kanban/calendar-utils"; + +type CalendarViewMode = "month" | "week" | "day"; + +export const useCalendarView = (checklist: Checklist) => { + const [currentDate, setCurrentDate] = useState(new Date()); + const [viewMode, setViewMode] = useState("month"); + + const events = useMemo( + () => parseItemsForCalendar(checklist.items), + [checklist.items] + ); + + const itemsByDate = useMemo( + () => getItemsGroupedByDate(checklist.items), + [checklist.items] + ); + + const calendarGrid = useMemo( + () => getCalendarGrid(currentDate.getFullYear(), currentDate.getMonth()), + [currentDate] + ); + + const unscheduledItems = useMemo( + () => checklist.items.filter((item) => !item.targetDate && !item.isArchived), + [checklist.items] + ); + + const _navigateMonth = useCallback((direction: number) => { + setCurrentDate((prev) => { + const next = new Date(prev); + next.setMonth(next.getMonth() + direction); + return next; + }); + }, []); + + const goToPreviousMonth = useCallback(() => _navigateMonth(-1), [_navigateMonth]); + const goToNextMonth = useCallback(() => _navigateMonth(1), [_navigateMonth]); + const goToToday = useCallback(() => setCurrentDate(new Date()), []); + + const exportICS = useCallback(() => { + const icsContent = generateICS(checklist.items, checklist.title); + const blob = new Blob([icsContent], { type: "text/calendar;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${checklist.title.replace(/[^a-zA-Z0-9]/g, "-")}.ics`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + }, [checklist]); + + const getEventsForDate = useCallback( + (date: Date): CalendarEvent[] => { + const pad = (n: number) => String(n).padStart(2, "0"); + const localDateStr = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; + return events.filter((e) => { + const eventDate = new Date(e.date); + const eventLocalStr = `${eventDate.getFullYear()}-${pad(eventDate.getMonth() + 1)}-${pad(eventDate.getDate())}`; + return eventLocalStr === localDateStr; + }); + }, + [events] + ); + + return { + currentDate, + viewMode, + setViewMode, + events, + itemsByDate, + calendarGrid, + unscheduledItems, + goToPreviousMonth, + goToNextMonth, + goToToday, + exportICS, + getEventsForDate, + }; +}; diff --git a/app/_hooks/kanban/useKanban.ts b/app/_hooks/kanban/useKanban.ts new file mode 100644 index 00000000..73d12358 --- /dev/null +++ b/app/_hooks/kanban/useKanban.ts @@ -0,0 +1,297 @@ +"use client"; + +import { useState, useEffect, useCallback, useRef } from "react"; +import { + DragEndEvent, + DragOverEvent, + DragStartEvent, +} from "@dnd-kit/core"; +import { arrayMove } from "@dnd-kit/sortable"; +import { Checklist, KanbanStatus, RecurrenceRule, Result } from "@/app/_types"; +import { + createItem, + updateItemStatus, + createBulkItems, + reorderItems, +} from "@/app/_server/actions/checklist-item"; +import { getListById } from "@/app/_server/actions/checklist"; +import { TaskStatus } from "@/app/_types/enums"; +import { getCurrentUser, getUserByChecklist } from "@/app/_server/actions/users"; +import { DEFAULT_KANBAN_STATUSES } from "@/app/_consts/kanban"; + +interface UseKanbanBoardProps { + checklist: Checklist; + onUpdate: (updatedChecklist: Checklist) => void; +} + +export const useKanbanBoard = ({ + checklist, + onUpdate, +}: UseKanbanBoardProps) => { + const [activeId, setActiveId] = useState(null); + const [overId, setOverId] = useState(null); + const [localChecklist, setLocalChecklist] = useState(checklist); + const [isLoading, setIsLoading] = useState(false); + const [showBulkPasteModal, setShowBulkPasteModal] = useState(false); + const [focusKey, setFocusKey] = useState(0); + + const dragOriginalStatusRef = useRef(null); + + const validStatusIds = (localChecklist.statuses || DEFAULT_KANBAN_STATUSES).map( + (s) => s.id + ); + + useEffect(() => { + if (checklist.id !== localChecklist.id || checklist.updatedAt !== localChecklist.updatedAt) { + setLocalChecklist(checklist); + setFocusKey((prev) => prev + 1); + } + }, [checklist.id, checklist.updatedAt, localChecklist.id, localChecklist.updatedAt]); + + const refreshChecklist = useCallback(async () => { + const checklistOwner = await getUserByChecklist( + localChecklist.id, + localChecklist.category || "Uncategorized" + ); + const updatedChecklist = await getListById( + localChecklist.id, + checklistOwner?.data?.username, + localChecklist.category + ); + if (updatedChecklist) { + setLocalChecklist(updatedChecklist); + onUpdate(updatedChecklist); + } + }, [localChecklist.id, localChecklist.category, onUpdate]); + + const getItemsByStatus = useCallback((status: string) => { + const firstStatus = (localChecklist.statuses || DEFAULT_KANBAN_STATUSES) + .sort((a, b) => a.order - b.order)[0]?.id || TaskStatus.TODO; + return localChecklist.items.filter((item) => { + if (item.isArchived) return false; + if (item.status === status) return true; + if (status === firstStatus) { + const hasValidStatus = validStatusIds.includes(item.status || ""); + return !hasValidStatus; + } + return false; + }); + }, [localChecklist.items, localChecklist.statuses, validStatusIds]); + + const handleDragStart = useCallback((event: DragStartEvent) => { + const id = event.active.id as string; + setActiveId(id); + + const item = localChecklist.items.find((i) => i.id === id); + dragOriginalStatusRef.current = item?.status || TaskStatus.TODO; + }, [localChecklist.items]); + + const handleDragOver = useCallback((event: DragOverEvent) => { + const { over } = event; + setOverId(over ? (over.id as string) : null); + + if (!over || !activeId) return; + + const activeItem = localChecklist.items.find((item) => item.id === activeId); + if (!activeItem) return; + + const overIdStr = over.id as string; + + let targetStatus: string | null = null; + if (validStatusIds.includes(overIdStr)) { + targetStatus = overIdStr; + } else { + const overItem = localChecklist.items.find((item) => item.id === overIdStr); + if (overItem) targetStatus = overItem.status || TaskStatus.TODO; + } + + if (targetStatus && activeItem.status !== targetStatus) { + setLocalChecklist((prev) => ({ + ...prev, + items: prev.items.map((item) => + item.id === activeId ? { ...item, status: targetStatus } : item + ), + })); + } + }, [activeId, localChecklist.items, validStatusIds]); + + const handleDragEnd = useCallback(async (event: DragEndEvent) => { + const { active, over } = event; + const origStatus = dragOriginalStatusRef.current; + setActiveId(null); + setOverId(null); + dragOriginalStatusRef.current = null; + + if (!over) { + if (origStatus) { + setLocalChecklist((prev) => ({ + ...prev, + items: prev.items.map((item) => + item.id === (active.id as string) ? { ...item, status: origStatus } : item + ), + })); + } + return; + } + + const activeIdStr = active.id as string; + const overIdStr = over.id as string; + + const droppedOnColumn = validStatusIds.includes(overIdStr); + const targetStatus = droppedOnColumn + ? overIdStr + : (localChecklist.items.find((item) => item.id === overIdStr)?.status || TaskStatus.TODO); + + const isCrossColumn = origStatus !== targetStatus; + + const columnItems = localChecklist.items.filter( + (item) => item.status === targetStatus && !item.isArchived && item.id !== activeIdStr + ); + + let insertIndex: number; + if (droppedOnColumn) { + insertIndex = columnItems.length; + } else { + const overIndex = columnItems.findIndex((item) => item.id === overIdStr); + insertIndex = overIndex === -1 ? columnItems.length : overIndex; + } + + const activeItem = localChecklist.items.find((item) => item.id === activeIdStr); + if (!activeItem) return; + + const updatedActiveItem = { ...activeItem, status: targetStatus }; + const newColumnItems = [...columnItems]; + newColumnItems.splice(insertIndex, 0, updatedActiveItem); + + const otherItems = localChecklist.items.filter( + (item) => (item.status !== targetStatus || item.isArchived) && item.id !== activeIdStr + ); + const allItems = [...otherItems, ...newColumnItems]; + + setLocalChecklist((prev) => ({ + ...prev, + items: allItems, + updatedAt: new Date().toISOString(), + })); + + if (isCrossColumn) { + await _handleItemStatusUpdate(activeIdStr, targetStatus); + } else { + if (droppedOnColumn || activeIdStr === overIdStr) return; + + const formData = new FormData(); + formData.append("listId", localChecklist.id); + formData.append("activeItemId", activeIdStr); + formData.append("overItemId", overIdStr); + formData.append("category", localChecklist.category || "Uncategorized"); + + const result = await reorderItems(formData); + if (result.success) { + await refreshChecklist(); + } + } + }, [localChecklist, validStatusIds, refreshChecklist]); + + const _handleItemStatusUpdate = async (itemId: string, newStatus: string) => { + const formData = new FormData(); + formData.append("listId", localChecklist.id); + formData.append("itemId", itemId); + formData.append("status", newStatus); + formData.append("category", localChecklist.category || "Uncategorized"); + + const result = await updateItemStatus(formData); + + if (result.success && result.data) { + setLocalChecklist(result.data as Checklist); + onUpdate(result.data as Checklist); + } else { + await refreshChecklist(); + } + }; + + const handleAddItem = async (text: string, recurrence?: RecurrenceRule) => { + setIsLoading(true); + const formData = new FormData(); + formData.append("listId", localChecklist.id); + formData.append("text", text); + formData.append("category", localChecklist.category || "Uncategorized"); + + const currentUser = await getCurrentUser(); + + if (recurrence) { + formData.append("recurrence", JSON.stringify(recurrence)); + } + + const result = await createItem( + localChecklist, + formData, + currentUser?.username + ); + + const checklistOwner = await getUserByChecklist( + localChecklist.id, + localChecklist.category || "Uncategorized" + ); + + const updatedList = await getListById( + localChecklist.id, + checklistOwner?.data?.username, + localChecklist.category + ); + if (updatedList) { + setLocalChecklist(updatedList); + onUpdate(updatedList); + } + setIsLoading(false); + + if (result.success && result.data) { + setFocusKey((prev) => prev + 1); + } + }; + + const handleBulkPaste = async (itemsText: string) => { + setIsLoading(true); + const formData = new FormData(); + formData.append("listId", localChecklist.id); + formData.append("itemsText", itemsText); + formData.append("category", localChecklist.category || "Uncategorized"); + const result = await createBulkItems(formData); + setIsLoading(false); + + if (result.success && result.data) { + setLocalChecklist(result.data as Checklist); + onUpdate(result.data as Checklist); + } + }; + + const handleItemUpdate = useCallback((updatedChecklist: Checklist) => { + setLocalChecklist(updatedChecklist); + onUpdate(updatedChecklist); + refreshChecklist(); + }, [onUpdate, refreshChecklist]); + + const activeItem = activeId + ? localChecklist.items.find((item) => item.id === activeId) + : null; + + return { + activeId, + overId, + localChecklist, + isLoading, + showBulkPasteModal, + setShowBulkPasteModal, + focusKey, + setFocusKey, + refreshChecklist, + handleItemUpdate, + getItemsByStatus, + handleDragStart, + handleDragOver, + handleDragEnd, + handleAddItem, + handleBulkPaste, + handleItemStatusUpdate: _handleItemStatusUpdate, + activeItem, + }; +}; diff --git a/app/_hooks/useKanbanItem.tsx b/app/_hooks/kanban/useKanbanItem.tsx similarity index 69% rename from app/_hooks/useKanbanItem.tsx rename to app/_hooks/kanban/useKanbanItem.tsx index 82df3329..3516d22f 100644 --- a/app/_hooks/useKanbanItem.tsx +++ b/app/_hooks/kanban/useKanbanItem.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef } from "react"; import { useTranslations } from "next-intl"; -import { Item, Checklist } from "@/app/_types"; +import { Item, Checklist, KanbanPriority, KanbanReminder } from "@/app/_types"; import { updateItem, deleteItem, @@ -11,6 +11,9 @@ import { } from "@/app/_server/actions/checklist-item"; import { ConfirmModal } from "@/app/_components/GlobalComponents/Modals/ConfirmationModals/ConfirmModal"; +const TIMER_STORAGE_KEY = (checklistId: string, itemId: string) => + `jotty-timer-${checklistId}-${itemId}`; + interface UseKanbanItemProps { item: Item; checklist: Checklist; @@ -49,6 +52,33 @@ export const useKanbanItem = ({ setTotalTime(Math.floor(existingTime / 1000)); }, [item.timeEntries]); + useEffect(() => { + const storageKey = TIMER_STORAGE_KEY(checklistId, item.id); + try { + const stored = localStorage.getItem(storageKey); + if (stored) { + const { startTime: storedStart, isRunning: storedRunning } = JSON.parse(stored); + if (storedRunning && storedStart) { + setStartTime(new Date(storedStart)); + setIsRunning(true); + setCurrentTime(Math.floor((Date.now() - new Date(storedStart).getTime()) / 1000)); + } + } + } catch {} + }, [checklistId, item.id]); + + useEffect(() => { + const storageKey = TIMER_STORAGE_KEY(checklistId, item.id); + if (isRunning && startTime) { + localStorage.setItem(storageKey, JSON.stringify({ + startTime: startTime.toISOString(), + isRunning: true, + })); + } else { + localStorage.removeItem(storageKey); + } + }, [isRunning, startTime, checklistId, item.id]); + useEffect(() => { let interval: NodeJS.Timeout; if (isRunning && startTime) { @@ -66,7 +96,7 @@ export const useKanbanItem = ({ } }, [isEditing]); - const saveTimerEntry = async (start: Date, end: Date) => { + const _saveTimerEntry = async (start: Date, end: Date) => { const newTimeEntry = { id: Date.now().toString(), startTime: start.toISOString(), @@ -93,12 +123,12 @@ export const useKanbanItem = ({ return result; }; - const handleTimerToggle = async () => { + function handleTimerToggle() { if (isRunning) { setIsRunning(false); if (startTime) { const endTime = new Date(); - await saveTimerEntry(startTime, endTime); + _saveTimerEntry(startTime, endTime); } setStartTime(null); setCurrentTime(0); @@ -107,20 +137,7 @@ export const useKanbanItem = ({ setStartTime(new Date()); setCurrentTime(0); } - }; - - const handleResetTimer = async () => { - const formData = new FormData(); - formData.append("listId", checklistId); - formData.append("itemId", item.id); - formData.append("timeEntries", JSON.stringify([])); - formData.append("category", category || "Uncategorized"); - const result = await updateItemStatus(formData); - setTotalTime(0); - if (result.success && result.data) { - onUpdate(result.data as Checklist); - } - }; + } const handleAddManualTime = async (minutes: number) => { const now = new Date(); @@ -148,17 +165,17 @@ export const useKanbanItem = ({ const stopTimerOnDrag = async () => { if (isRunning && startTime) { const endTime = new Date(); - await saveTimerEntry(startTime, endTime); + await _saveTimerEntry(startTime, endTime); setIsRunning(false); setStartTime(null); setCurrentTime(0); } }; - const handleEdit = () => { + function handleEdit() { setIsEditing(true); setEditText(item.text); - }; + } const handleSave = async () => { setIsEditing(false); @@ -176,12 +193,12 @@ export const useKanbanItem = ({ } }; - const handleCancel = () => { + function handleCancel() { setEditText(item.text); setIsEditing(false); - }; + } - const handleKeyDown = (e: React.KeyboardEvent) => { + function handleKeyDown(e: React.KeyboardEvent) { if (e.key === "Enter") { e.preventDefault(); e.stopPropagation(); @@ -191,7 +208,7 @@ export const useKanbanItem = ({ e.stopPropagation(); handleCancel(); } - }; + } const handleStatusChange = async (newStatus: string) => { const formData = new FormData(); @@ -205,7 +222,55 @@ export const useKanbanItem = ({ } }; - const confirmDelete = async () => { + const handlePriorityChange = async (priority: KanbanPriority) => { + const formData = new FormData(); + formData.append("listId", checklistId); + formData.append("itemId", item.id); + formData.append("priority", priority); + formData.append("category", category || "Uncategorized"); + const result = await updateItem(checklist, formData); + if (result.success && result.data) { + onUpdate(result.data as Checklist); + } + }; + + const handleScoreChange = async (score: number) => { + const formData = new FormData(); + formData.append("listId", checklistId); + formData.append("itemId", item.id); + formData.append("score", score.toString()); + formData.append("category", category || "Uncategorized"); + const result = await updateItem(checklist, formData); + if (result.success && result.data) { + onUpdate(result.data as Checklist); + } + }; + + const handleAssigneeChange = async (assignee: string) => { + const formData = new FormData(); + formData.append("listId", checklistId); + formData.append("itemId", item.id); + formData.append("assignee", assignee); + formData.append("category", category || "Uncategorized"); + const result = await updateItem(checklist, formData); + if (result.success && result.data) { + onUpdate(result.data as Checklist); + } + }; + + const handleReminderSet = async (reminder: KanbanReminder | null) => { + const formData = new FormData(); + formData.append("listId", checklistId); + formData.append("itemId", item.id); + formData.append("reminder", reminder ? JSON.stringify(reminder) : ""); + formData.append("category", category || "Uncategorized"); + const result = await updateItem(checklist, formData); + if (result.success && result.data) { + onUpdate(result.data as Checklist); + } + }; + + const _confirmDelete = async () => { const formData = new FormData(); formData.append("listId", checklistId); formData.append("itemId", item.id); @@ -216,7 +281,7 @@ export const useKanbanItem = ({ onUpdate({ id: checklistId, title: "", - type: "task", + type: "kanban", items: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -244,7 +309,6 @@ export const useKanbanItem = ({ currentTime, totalTime, handleTimerToggle, - handleResetTimer, handleAddManualTime, stopTimerOnDrag, isEditing, @@ -256,13 +320,17 @@ export const useKanbanItem = ({ handleCancel, handleKeyDown, handleStatusChange, + handlePriorityChange, + handleScoreChange, + handleAssigneeChange, + handleReminderSet, handleDelete: () => setShowDeleteModal(true), handleArchive, DeleteModal: () => ( setShowDeleteModal(false)} - onConfirm={confirmDelete} + onConfirm={_confirmDelete} title={t("common.delete")} message={t("common.confirmDeleteItem", { itemTitle: item.text })} confirmText={t("common.delete")} diff --git a/app/_hooks/kanban/useKanbanReminders.ts b/app/_hooks/kanban/useKanbanReminders.ts new file mode 100644 index 00000000..749882b8 --- /dev/null +++ b/app/_hooks/kanban/useKanbanReminders.ts @@ -0,0 +1,51 @@ +"use client"; + +import { useEffect, useRef, useCallback } from "react"; +import { Checklist } from "@/app/_types"; +import { getDueReminders } from "@/app/_utils/kanban/reminder-utils"; + +const REMINDER_CHECK_INTERVAL = 60000; + +export const useKanbanReminders = (checklist: Checklist) => { + const notifiedRef = useRef>(new Set()); + + const _sendNotification = useCallback((title: string, body: string) => { + if (!("Notification" in window)) return; + if (Notification.permission !== "granted") return; + + new Notification(title, { + body, + icon: "/icons/icon-192x192.png", + tag: `jotty-reminder-${Date.now()}`, + }); + }, []); + + const checkReminders = useCallback(() => { + const dueItems = getDueReminders(checklist.items); + + dueItems.forEach((item) => { + if (notifiedRef.current.has(item.id)) return; + notifiedRef.current.add(item.id); + + _sendNotification( + "Jotty Reminder", + `${item.text} - ${checklist.title}` + ); + }); + }, [checklist.items, checklist.title, _sendNotification]); + + useEffect(() => { + checkReminders(); + const interval = setInterval(checkReminders, REMINDER_CHECK_INTERVAL); + return () => clearInterval(interval); + }, [checkReminders]); + + const requestPermission = useCallback(async () => { + if (!("Notification" in window)) return false; + if (Notification.permission === "granted") return true; + const permission = await Notification.requestPermission(); + return permission === "granted"; + }, []); + + return { requestPermission, checkReminders }; +}; diff --git a/app/_hooks/useChecklist.tsx b/app/_hooks/useChecklist.tsx index 0db3bbe3..751fa70a 100644 --- a/app/_hooks/useChecklist.tsx +++ b/app/_hooks/useChecklist.tsx @@ -10,7 +10,7 @@ import { useSensor, useSensors, } from "@dnd-kit/core"; -import { Checklist, Item, RecurrenceRule } from "@/app/_types"; +import { Checklist, ChecklistType, Item, RecurrenceRule } from "@/app/_types"; import { deleteList, convertChecklistType, @@ -577,8 +577,8 @@ export const useChecklist = ({ setShowConversionModal(true); }; - const getNewType = (currentType: "simple" | "task"): "simple" | "task" => { - return currentType === "simple" ? "task" : "simple"; + const getNewType = (currentType: ChecklistType): ChecklistType => { + return currentType === "simple" ? "kanban" : "simple"; }; const handleConfirmConversion = async () => { diff --git a/app/_hooks/useChecklistHome.tsx b/app/_hooks/useChecklistHome.tsx index c6025d21..74e14b8a 100644 --- a/app/_hooks/useChecklistHome.tsx +++ b/app/_hooks/useChecklistHome.tsx @@ -195,7 +195,7 @@ export const useChecklistHome = ({ lists, user }: UseChecklistHomeProps) => { items?.filter((item) => isItemCompleted(item, list.type)).length || 0; }); - const taskLists = lists.filter((list) => list.type === "task").length; + const taskLists = lists.filter((list) => list.type === "kanban" || list.type === "task").length; return { totalLists, totalItems, completedItems, taskLists }; }, [lists]); @@ -207,7 +207,7 @@ export const useChecklistHome = ({ lists, user }: UseChecklistHomeProps) => { const pinned = getPinnedLists(); const recent = getRecentLists(); - const taskLists = recent.filter((list) => list.type === "task"); + const taskLists = recent.filter((list) => list.type === "kanban" || list.type === "task"); const simpleLists = recent.filter((list) => list.type === "simple"); const filterOptions = [ diff --git a/app/_hooks/useKanbanBoard.tsx b/app/_hooks/useKanbanBoard.tsx deleted file mode 100644 index 1b8c0837..00000000 --- a/app/_hooks/useKanbanBoard.tsx +++ /dev/null @@ -1,267 +0,0 @@ -"use client"; - -import { useState, useEffect } from "react"; -import { DragEndEvent, DragStartEvent } from "@dnd-kit/core"; -import { arrayMove } from "@dnd-kit/sortable"; -import { Checklist, KanbanStatus, RecurrenceRule, Result } from "@/app/_types"; -import { - createItem, - updateItemStatus, - createBulkItems, - reorderItems, -} from "@/app/_server/actions/checklist-item"; -import { - getListById, - getUserChecklists, -} from "@/app/_server/actions/checklist"; -import { TaskStatus, TaskStatusLabels } from "@/app/_types/enums"; -import { getCurrentUser, getUserByChecklist } from "../_server/actions/users"; - -interface UseKanbanBoardProps { - checklist: Checklist; - onUpdate: (updatedChecklist: Checklist) => void; -} - -const defaultStatuses: KanbanStatus[] = [ - { - id: TaskStatus.TODO, - label: TaskStatusLabels.TODO, - order: 0, - }, - { - id: TaskStatus.IN_PROGRESS, - label: TaskStatusLabels.IN_PROGRESS, - order: 1, - }, - { - id: TaskStatus.COMPLETED, - label: TaskStatusLabels.COMPLETED, - order: 2, - }, - { - id: TaskStatus.PAUSED, - label: TaskStatusLabels.PAUSED, - order: 3, - }, -]; - -export const useKanbanBoard = ({ - checklist, - onUpdate, -}: UseKanbanBoardProps) => { - const [activeId, setActiveId] = useState(null); - const [localChecklist, setLocalChecklist] = useState(checklist); - const [isLoading, setIsLoading] = useState(false); - const [showBulkPasteModal, setShowBulkPasteModal] = useState(false); - const [focusKey, setFocusKey] = useState(0); - - const validStatusIds = (localChecklist.statuses || defaultStatuses).map( - (s) => s.id - ); - - useEffect(() => { - if (checklist.id !== localChecklist.id || checklist.updatedAt !== localChecklist.updatedAt) { - setLocalChecklist(checklist); - setFocusKey((prev) => prev + 1); - } - }, [checklist.id, checklist.updatedAt, localChecklist.id, localChecklist.updatedAt]); - - const refreshChecklist = async () => { - const result = (await getUserChecklists()) as Result; - if (result.success && result.data) { - const updatedChecklist = result.data.find( - (list) => list.id === checklist.id - ); - if (updatedChecklist) { - setLocalChecklist(updatedChecklist); - onUpdate(updatedChecklist); - } - } - }; - - const getItemsByStatus = (status: string) => { - return localChecklist.items.filter( - (item) => item.status === status && !item.isArchived - ); - }; - - const handleDragStart = (event: DragStartEvent) => { - setActiveId(event.active.id as string); - }; - - const handleDragEnd = async (event: DragEndEvent) => { - const { active, over } = event; - setActiveId(null); - - if (!over) return; - - const activeId = active.id as string; - const overId = over.id as string; - - if (activeId === overId) return; - - const activeItem = localChecklist.items.find( - (item) => item.id === activeId - ); - if (!activeItem) return; - - let newStatus: string; - let isReordering = false; - - if (validStatusIds.includes(overId)) { - newStatus = overId; - } else { - const overItem = localChecklist.items.find((item) => item.id === overId); - if (!overItem) return; - newStatus = overItem.status || TaskStatus.TODO; - - if (activeItem.status === newStatus) { - isReordering = true; - } - } - - if (isReordering) { - const itemsWithStatus = localChecklist.items.filter( - (item) => item.status === newStatus - ); - const oldIndex = itemsWithStatus.findIndex( - (item) => item.id === activeId - ); - const newIndex = itemsWithStatus.findIndex((item) => item.id === overId); - - if (oldIndex === -1 || newIndex === -1) return; - - const reorderedItems = arrayMove(itemsWithStatus, oldIndex, newIndex); - - const otherItems = localChecklist.items.filter( - (item) => item.status !== newStatus - ); - - const allItems = [...otherItems, ...reorderedItems]; - - const optimisticChecklist = { - ...localChecklist, - items: allItems, - updatedAt: new Date().toISOString(), - }; - setLocalChecklist(optimisticChecklist); - - const formData = new FormData(); - formData.append("listId", localChecklist.id); - formData.append("activeItemId", activeId); - formData.append("overItemId", overId); - formData.append("category", localChecklist.category || "Uncategorized"); - - const result = await reorderItems(formData); - - if (result.success) { - await refreshChecklist(); - } - } else { - await handleItemStatusUpdate(activeId, newStatus); - } - }; - - const handleAddItem = async (text: string, recurrence?: RecurrenceRule) => { - setIsLoading(true); - const formData = new FormData(); - formData.append("listId", localChecklist.id); - formData.append("text", text); - formData.append("category", localChecklist.category || "Uncategorized"); - - const currentUser = await getCurrentUser(); - - if (recurrence) { - formData.append("recurrence", JSON.stringify(recurrence)); - } - - const result = await createItem( - localChecklist, - formData, - currentUser?.username - ); - - const checklistOwner = await getUserByChecklist( - localChecklist.id, - localChecklist.category || "Uncategorized" - ); - - const updatedList = await getListById( - localChecklist.id, - checklistOwner?.data?.username, - localChecklist.category - ); - if (updatedList) { - setLocalChecklist(updatedList); - onUpdate(updatedList); - } - setIsLoading(false); - - if (result.success && result.data) { - setFocusKey((prev) => prev + 1); - } else { - console.error("Failed to create item:", result.error); - } - }; - - const handleBulkPaste = async (itemsText: string) => { - setIsLoading(true); - const formData = new FormData(); - formData.append("listId", localChecklist.id); - formData.append("itemsText", itemsText); - formData.append("category", localChecklist.category || "Uncategorized"); - const result = await createBulkItems(formData); - setIsLoading(false); - - if (result.success && result.data) { - setLocalChecklist(result.data as Checklist); - onUpdate(result.data as Checklist); - } - }; - - const activeItem = activeId - ? localChecklist.items.find((item) => item.id === activeId) - : null; - - const handleItemStatusUpdate = async (itemId: string, newStatus: string) => { - const item = localChecklist.items.find((item) => item.id === itemId); - if (!item) { - return; - } - - if (item.status === newStatus) { - return; - } - - const formData = new FormData(); - formData.append("listId", localChecklist.id); - formData.append("itemId", itemId); - formData.append("status", newStatus); - formData.append("category", localChecklist.category || "Uncategorized"); - - const result = await updateItemStatus(formData); - - if (result.success && result.data) { - setLocalChecklist(result.data as Checklist); - onUpdate(result.data as Checklist); - } - }; - - return { - activeId, - localChecklist, - isLoading, - showBulkPasteModal, - setShowBulkPasteModal, - focusKey, - setFocusKey, - refreshChecklist, - getItemsByStatus, - handleDragStart, - handleDragEnd, - handleAddItem, - handleBulkPaste, - handleItemStatusUpdate, - activeItem, - }; -}; diff --git a/app/_server/actions/checklist-item/bulk-operations.ts b/app/_server/actions/checklist-item/bulk-operations.ts index 67876186..93587cf9 100644 --- a/app/_server/actions/checklist-item/bulk-operations.ts +++ b/app/_server/actions/checklist-item/bulk-operations.ts @@ -75,7 +75,7 @@ export const createBulkItems = async ( createdAt: now, lastModifiedBy: currentUser, lastModifiedAt: now, - ...(list.type === "task" && { + ...((list.type === "kanban" || list.type === "task") && { status: TaskStatus.TODO, timeEntries: [], history: [ diff --git a/app/_server/actions/checklist-item/crud.ts b/app/_server/actions/checklist-item/crud.ts index 9b0c9b98..be97d068 100644 --- a/app/_server/actions/checklist-item/crud.ts +++ b/app/_server/actions/checklist-item/crud.ts @@ -38,7 +38,7 @@ export const updateItem = async ( try { const listId = formData.get("listId") as string; const itemId = formData.get("itemId") as string; - const completed = formData.get("completed") === "true"; + const completedRaw = formData.get("completed"); const text = formData.get("text") as string; const description = formData.get("description") as string; const category = formData.get("category") as string; @@ -125,13 +125,26 @@ export const updateItem = async ( ? Array.from(new Set([...existingTags.map(normalizeTag), ...textInlineTags])).filter(Boolean) : existingTags; + const priority = formData.get("priority") as string | null; + const score = formData.get("score") as string | null; + const assignee = formData.get("assignee") as string | null; + const reminder = formData.get("reminder") as string | null; + const targetDate = formData.get("targetDate") as string | null; + const updatedList = { ...checklist, items: findAndUpdateItem(checklist.items, itemId, { - completed, + ...(completedRaw !== null && { completed: completedRaw === "true" }), ...(text && { text }), ...(description !== null && description !== undefined && { description }), + ...(priority !== null && { priority: priority || undefined }), + ...(score !== null && { score: score ? parseInt(score) : undefined }), + ...(assignee !== null && { assignee: assignee || undefined }), + ...(reminder !== null && { + reminder: reminder ? JSON.parse(reminder) : undefined, + }), + ...(targetDate !== null && { targetDate: targetDate || undefined }), lastModifiedBy: currentUser, lastModifiedAt: now, }), @@ -256,7 +269,7 @@ export const createItem = async ( return TaskStatus.TODO; }; - const defaultStatus = list.type === "task" ? getDefaultStatus() : undefined; + const defaultStatus = (list.type === "kanban" || list.type === "task") ? getDefaultStatus() : undefined; const shiftedItems = list.items.map((item) => ({ ...item, @@ -273,7 +286,7 @@ export const createItem = async ( createdAt: now, lastModifiedBy: currentUser, lastModifiedAt: now, - ...(list.type === "task" && + ...((list.type === "kanban" || list.type === "task") && defaultStatus && { status: defaultStatus, timeEntries, diff --git a/app/_server/actions/checklist-item/sub-items.ts b/app/_server/actions/checklist-item/sub-items.ts index c8c7251a..42cb24d3 100644 --- a/app/_server/actions/checklist-item/sub-items.ts +++ b/app/_server/actions/checklist-item/sub-items.ts @@ -94,7 +94,7 @@ export const createSubItem = async ( lastModifiedAt: now, }; - if (list.type === "task") { + if (list.type === "kanban" || list.type === "task") { newSubItem.status = TaskStatus.TODO; newSubItem.timeEntries = []; newSubItem.history = [ diff --git a/app/_server/actions/checklist/converters.ts b/app/_server/actions/checklist/converters.ts index 44a912dd..9e23412c 100644 --- a/app/_server/actions/checklist/converters.ts +++ b/app/_server/actions/checklist/converters.ts @@ -74,14 +74,27 @@ export const convertChecklistType = async (formData: FormData) => { let convertedItems: any[]; - if (newType === "task") { - convertedItems = (list.items || []).map((item) => ({ - ...item, - status: - item.status || - (item.completed ? TaskStatus.COMPLETED : TaskStatus.TODO), - timeEntries: item.timeEntries || [], - })); + if (newType === "kanban" || newType === "task") { + const statuses = list.statuses || []; + const completionStatus = statuses.find((s) => s.autoComplete) || statuses.find((s) => s.id === TaskStatus.COMPLETED); + const completionStatusId = completionStatus?.id || TaskStatus.COMPLETED; + const sortedStatuses = [...statuses].sort((a, b) => a.order - b.order); + const firstStatusId = sortedStatuses[0]?.id || TaskStatus.TODO; + + convertedItems = (list.items || []).map((item) => { + let status = item.status; + if (!status) { + status = item.completed ? completionStatusId : firstStatusId; + } else if (statuses.length > 0 && !statuses.some((s) => s.id === status)) { + status = item.completed ? completionStatusId : firstStatusId; + } + return { + ...item, + status, + completed: item.completed || (completionStatus?.autoComplete && status === completionStatusId) || false, + timeEntries: item.timeEntries || [], + }; + }); } else { convertedItems = (list.items || []).map((item) => ({ ...item, @@ -100,6 +113,8 @@ export const convertChecklistType = async (formData: FormData) => { type: newType, items: convertedItems as Item[], updatedAt: new Date().toISOString(), + ...(list.statuses && { statuses: list.statuses }), + ...(list.tags && { tags: list.tags }), }; await serverWriteFile(filePath, listToMarkdown(updatedList)); diff --git a/app/_server/actions/checklist/parsers.ts b/app/_server/actions/checklist/parsers.ts index 62d84534..9a941523 100644 --- a/app/_server/actions/checklist/parsers.ts +++ b/app/_server/actions/checklist/parsers.ts @@ -9,8 +9,8 @@ import { } from "@/app/_utils/recurrence-utils"; export const getChecklistType = (content: string): ChecklistType => { - if (content.includes("checklistType: task")) { - return "task"; + if (content.includes("checklistType: task") || content.includes("checklistType: kanban")) { + return "kanban"; } return "simple"; diff --git a/app/_server/actions/checklist/readers.ts b/app/_server/actions/checklist/readers.ts index d6f54662..926dda3a 100644 --- a/app/_server/actions/checklist/readers.ts +++ b/app/_server/actions/checklist/readers.ts @@ -188,9 +188,8 @@ export const readListsRecursively = async ( typeof metadata?.uuid === "string" ? metadata.uuid : undefined, title: typeof metadata?.title === "string" ? metadata.title : id, type: - metadata?.checklistType === "task" || - metadata?.checklistType === "simple" - ? metadata.checklistType + metadata?.checklistType === "task" || metadata?.checklistType === "kanban" + ? "kanban" : "simple", category: categoryPath, items: [], diff --git a/app/_server/actions/kanban/calendar.ts b/app/_server/actions/kanban/calendar.ts new file mode 100644 index 00000000..b0b51d6e --- /dev/null +++ b/app/_server/actions/kanban/calendar.ts @@ -0,0 +1,36 @@ +"use server"; + +import { getFormData } from "@/app/_utils/global-utils"; +import { getListById } from "@/app/_server/actions/checklist"; +import { generateICS } from "@/app/_utils/kanban/calendar-utils"; +import { parseItemsForCalendar, CalendarEvent } from "@/app/_utils/kanban/calendar-utils"; + +export const exportBoardAsICS = async (formData: FormData) => { + try { + const { listId, category } = getFormData(formData, ["listId", "category"]); + + const list = await getListById(listId, undefined, category); + if (!list) return { error: "Board not found" }; + + const icsContent = generateICS(list.items, list.title); + return { success: true, data: icsContent }; + } catch (error) { + console.error("Error exporting calendar:", error); + return { error: "Failed to export calendar" }; + } +}; + +export const getCalendarEvents = async (formData: FormData) => { + try { + const { listId, category } = getFormData(formData, ["listId", "category"]); + + const list = await getListById(listId, undefined, category); + if (!list) return { error: "Board not found" }; + + const events: CalendarEvent[] = parseItemsForCalendar(list.items); + return { success: true, data: events }; + } catch (error) { + console.error("Error fetching calendar events:", error); + return { error: "Failed to fetch calendar events" }; + } +}; diff --git a/app/_server/actions/kanban/index.ts b/app/_server/actions/kanban/index.ts new file mode 100644 index 00000000..5ae7879c --- /dev/null +++ b/app/_server/actions/kanban/index.ts @@ -0,0 +1,12 @@ +export { + updateKanbanItemPriority, + updateKanbanItemScore, + assignKanbanItem, + setKanbanItemReminder, + markReminderNotified, +} from "./items"; + +export { + exportBoardAsICS, + getCalendarEvents, +} from "./calendar"; diff --git a/app/_server/actions/kanban/items.ts b/app/_server/actions/kanban/items.ts new file mode 100644 index 00000000..d27a9467 --- /dev/null +++ b/app/_server/actions/kanban/items.ts @@ -0,0 +1,257 @@ +"use server"; + +import path from "path"; +import { Checklist, Item, KanbanPriority, KanbanReminder } from "@/app/_types"; +import { CHECKLISTS_FOLDER } from "@/app/_consts/checklists"; +import { ItemTypes, Modes, PermissionTypes } from "@/app/_types/enums"; +import { getCurrentUser } from "@/app/_server/actions/users"; +import { getUserModeDir, serverWriteFile } from "@/app/_server/actions/file"; +import { revalidatePath } from "next/cache"; +import { listToMarkdown } from "@/app/_utils/checklist-utils"; +import { getFormData } from "@/app/_utils/global-utils"; +import { checkUserPermission } from "@/app/_server/actions/sharing"; +import { broadcast } from "@/app/_server/ws/broadcast"; +import { getListById } from "@/app/_server/actions/checklist"; + +const _getFilePath = async (list: Checklist): Promise => { + const categoryDir = list.category || "Uncategorized"; + const filename = `${list.id}.md`; + + if (list.owner) { + return path.join( + process.cwd(), "data", CHECKLISTS_FOLDER, list.owner, categoryDir, filename + ); + } + + const userDir = await getUserModeDir(Modes.CHECKLISTS); + return path.join(userDir, categoryDir, filename); +}; + +const _findItem = (items: Item[], itemId: string): Item | null => { + for (const item of items) { + if (item.id === itemId) return item; + if (item.children) { + const found = _findItem(item.children, itemId); + if (found) return found; + } + } + return null; +}; + +const _updateItemInList = (items: Item[], itemId: string, updater: (item: Item) => Item): Item[] => + items.map((item) => { + if (item.id === itemId) return updater(item); + if (item.children) { + return { ...item, children: _updateItemInList(item.children, itemId, updater) }; + } + return item; + }); + +const _saveAndBroadcast = async (list: Checklist) => { + const filePath = await _getFilePath(list); + await serverWriteFile(filePath, listToMarkdown(list)); + + const currentUser = await getCurrentUser(); + await broadcast({ + type: "checklist", + action: "updated", + entityId: list.uuid || list.id, + username: currentUser?.username || "", + }); + + try { + revalidatePath("/"); + } catch {} +}; + +export const updateKanbanItemPriority = async (formData: FormData) => { + try { + const { listId, itemId, priority, category } = getFormData(formData, [ + "listId", "itemId", "priority", "category", + ]); + + const currentUser = await getCurrentUser(); + if (!currentUser) return { error: "Not authenticated" }; + + const canEdit = await checkUserPermission( + listId, category, ItemTypes.CHECKLIST, currentUser.username, PermissionTypes.EDIT + ); + if (!canEdit) return { error: "Permission denied" }; + + const list = await getListById(listId, undefined, category); + if (!list) return { error: "List not found" }; + + const now = new Date().toISOString(); + const updatedList: Checklist = { + ...list, + items: _updateItemInList(list.items, itemId, (item) => ({ + ...item, + priority: priority as KanbanPriority, + lastModifiedBy: currentUser.username, + lastModifiedAt: now, + })), + updatedAt: now, + }; + + await _saveAndBroadcast(updatedList); + return { success: true, data: updatedList }; + } catch (error) { + console.error("Error updating priority:", error); + return { error: "Failed to update priority" }; + } +}; + +export const updateKanbanItemScore = async (formData: FormData) => { + try { + const { listId, itemId, score, category } = getFormData(formData, [ + "listId", "itemId", "score", "category", + ]); + + const currentUser = await getCurrentUser(); + if (!currentUser) return { error: "Not authenticated" }; + + const canEdit = await checkUserPermission( + listId, category, ItemTypes.CHECKLIST, currentUser.username, PermissionTypes.EDIT + ); + if (!canEdit) return { error: "Permission denied" }; + + const list = await getListById(listId, undefined, category); + if (!list) return { error: "List not found" }; + + const now = new Date().toISOString(); + const updatedList: Checklist = { + ...list, + items: _updateItemInList(list.items, itemId, (item) => ({ + ...item, + score: parseInt(score), + lastModifiedBy: currentUser.username, + lastModifiedAt: now, + })), + updatedAt: now, + }; + + await _saveAndBroadcast(updatedList); + return { success: true, data: updatedList }; + } catch (error) { + console.error("Error updating score:", error); + return { error: "Failed to update score" }; + } +}; + +export const assignKanbanItem = async (formData: FormData) => { + try { + const { listId, itemId, assignee, category } = getFormData(formData, [ + "listId", "itemId", "assignee", "category", + ]); + + const currentUser = await getCurrentUser(); + if (!currentUser) return { error: "Not authenticated" }; + + const canEdit = await checkUserPermission( + listId, category, ItemTypes.CHECKLIST, currentUser.username, PermissionTypes.EDIT + ); + if (!canEdit) return { error: "Permission denied" }; + + if (assignee) { + const assigneeCanRead = await checkUserPermission( + listId, category, ItemTypes.CHECKLIST, assignee, PermissionTypes.READ + ); + if (!assigneeCanRead) return { error: "Assignee does not have access to this board" }; + } + + const list = await getListById(listId, undefined, category); + if (!list) return { error: "List not found" }; + + const now = new Date().toISOString(); + const updatedList: Checklist = { + ...list, + items: _updateItemInList(list.items, itemId, (item) => ({ + ...item, + assignee: assignee || undefined, + lastModifiedBy: currentUser.username, + lastModifiedAt: now, + })), + updatedAt: now, + }; + + await _saveAndBroadcast(updatedList); + return { success: true, data: updatedList }; + } catch (error) { + console.error("Error assigning item:", error); + return { error: "Failed to assign item" }; + } +}; + +export const setKanbanItemReminder = async (formData: FormData) => { + try { + const { listId, itemId, reminder: reminderStr, category } = getFormData(formData, [ + "listId", "itemId", "reminder", "category", + ]); + + const currentUser = await getCurrentUser(); + if (!currentUser) return { error: "Not authenticated" }; + + const canEdit = await checkUserPermission( + listId, category, ItemTypes.CHECKLIST, currentUser.username, PermissionTypes.EDIT + ); + if (!canEdit) return { error: "Permission denied" }; + + const list = await getListById(listId, undefined, category); + if (!list) return { error: "List not found" }; + + let reminder: KanbanReminder | undefined; + if (reminderStr) { + try { + reminder = JSON.parse(reminderStr); + } catch { + reminder = { datetime: reminderStr }; + } + } + + const now = new Date().toISOString(); + const updatedList: Checklist = { + ...list, + items: _updateItemInList(list.items, itemId, (item) => ({ + ...item, + reminder, + lastModifiedBy: currentUser.username, + lastModifiedAt: now, + })), + updatedAt: now, + }; + + await _saveAndBroadcast(updatedList); + return { success: true, data: updatedList }; + } catch (error) { + console.error("Error setting reminder:", error); + return { error: "Failed to set reminder" }; + } +}; + +export const markReminderNotified = async (formData: FormData) => { + try { + const { listId, itemId, category } = getFormData(formData, [ + "listId", "itemId", "category", + ]); + + const list = await getListById(listId, undefined, category); + if (!list) return { error: "List not found" }; + + const updatedList: Checklist = { + ...list, + items: _updateItemInList(list.items, itemId, (item) => ({ + ...item, + reminder: item.reminder + ? { ...item.reminder, notified: true } + : undefined, + })), + updatedAt: new Date().toISOString(), + }; + + await _saveAndBroadcast(updatedList); + return { success: true, data: updatedList }; + } catch (error) { + console.error("Error marking reminder:", error); + return { error: "Failed to mark reminder" }; + } +}; diff --git a/app/_server/actions/sharing/index.ts b/app/_server/actions/sharing/index.ts index f7ef7e8e..91d6cce7 100644 --- a/app/_server/actions/sharing/index.ts +++ b/app/_server/actions/sharing/index.ts @@ -18,6 +18,7 @@ export { export { getAllSharedItemsForUser, getAllSharedItems, + getUsersWithAccess, } from "./queries"; export { diff --git a/app/_server/actions/sharing/queries.ts b/app/_server/actions/sharing/queries.ts index bf0dab98..31106522 100644 --- a/app/_server/actions/sharing/queries.ts +++ b/app/_server/actions/sharing/queries.ts @@ -16,6 +16,29 @@ export const getAllSharedItemsForUser = async ( }; }; +export const getUsersWithAccess = async ( + checklistId: string, + checklistUuid?: string +): Promise => { + const sharingData = await readShareFile(ItemTypes.CHECKLIST); + const users: string[] = []; + + for (const [username, entries] of Object.entries(sharingData)) { + if (username === "public") continue; + for (const entry of entries) { + if ( + (checklistUuid && entry.uuid === checklistUuid) || + (entry.id === checklistId) + ) { + users.push(username); + break; + } + } + } + + return users; +}; + export const getAllSharedItems = async (): Promise<{ notes: Array<{ id: string; category: string }>; checklists: Array<{ id: string; category: string }>; diff --git a/app/_server/actions/stats/index.ts b/app/_server/actions/stats/index.ts index 5136f402..e958a923 100644 --- a/app/_server/actions/stats/index.ts +++ b/app/_server/actions/stats/index.ts @@ -74,7 +74,7 @@ export const getUserStats = async (username?: string): Promise completedItems++; } - if (list.type === "task" && item.status) { + if ((list.type === "kanban" || list.type === "task") && item.status) { totalTasks++; switch (item.status) { case TaskStatus.COMPLETED: diff --git a/app/_translations/en.json b/app/_translations/en.json index 91b340fc..9791d449 100644 --- a/app/_translations/en.json +++ b/app/_translations/en.json @@ -462,6 +462,7 @@ "checklistType": "Checklist Type", "simpleChecklist": "Simple Checklist", "taskProject": "Task Project", + "kanbanBoard": "Kanban Board", "basicTodoItems": "Basic todo items", "withTimeTracking": "With time tracking", "noDescription": "No description", @@ -505,6 +506,7 @@ "convert": "Convert", "convertToSimpleChecklist": "Convert to simple checklist", "convertToTaskProject": "Convert to task project", + "convertToKanbanBoard": "Convert to Kanban Board", "pasteYourList": "Paste your list (one item per line)", "addItems": "Add {count} Items", "itemsWillBeAdded": "{count} items will be added", @@ -558,6 +560,49 @@ "addStatus": "Add status", "autoComplete": "Auto complete" }, + "kanban": { + "priority": "Priority", + "score": "Score", + "assignee": "Assignee", + "reminder": "Reminder", + "usernamePlaceholder": "username", + "scoreLabel": "Score: {score}", + "today": "Today", + "exportIcs": "Export .ics", + "unscheduled": "Unscheduled ({count})", + "moreEvents": "+{count} more", + "critical": "Critical", + "high": "High", + "medium": "Medium", + "low": "Low", + "none": "None", + "weekdaysSun": "Sun", + "weekdaysMon": "Mon", + "weekdaysTue": "Tue", + "weekdaysWed": "Wed", + "weekdaysThu": "Thu", + "weekdaysFri": "Fri", + "weekdaysSat": "Sat", + "title": "Kanban", + "calendar": "Calendar", + "boards": "Boards", + "addItem": "Add Item", + "itemTitle": "Item Title", + "manageStatuses": "Manage Statuses", + "viewArchived": "View Archived", + "noBoards": "No boards found", + "noBoardsYet": "No kanban boards yet", + "createFirstBoard": "Create your first kanban board to start managing your projects.", + "newBoard": "New Board", + "tryAdjustingFilters": "Try adjusting your filters or create a new board.", + "completed": "Completed", + "todo": "To Do", + "inProgress": "In Progress", + "recentBoards": "Recent Boards", + "showAllBoards": "Show all boards", + "unassigned": "Unassigned", + "targetDate": "Target Date" + }, "profile": { "userProfile": "User", "manageAccount": "Manage your account settings and preferences", diff --git a/app/_types/checklist.ts b/app/_types/checklist.ts index 6790b654..dc134317 100644 --- a/app/_types/checklist.ts +++ b/app/_types/checklist.ts @@ -1,6 +1,13 @@ import { ItemTypes } from "./enums"; -export type ChecklistType = "simple" | "task"; +export type ChecklistType = "simple" | "task" | "kanban"; + +export type KanbanPriority = "critical" | "high" | "medium" | "low" | "none"; + +export interface KanbanReminder { + datetime: string; + notified?: boolean; +} export interface TimeEntry { id: string; @@ -53,6 +60,10 @@ export interface Item { archivedAt?: string; archivedBy?: string; previousStatus?: string; + priority?: KanbanPriority; + score?: number; + assignee?: string; + reminder?: KanbanReminder; } export interface List { diff --git a/app/_types/enums.ts b/app/_types/enums.ts index 4284a079..5f99714f 100644 --- a/app/_types/enums.ts +++ b/app/_types/enums.ts @@ -13,6 +13,15 @@ export enum ItemTypes { export enum ChecklistsTypes { SIMPLE = "simple", TASK = "task", + KANBAN = "kanban", +} + +export enum KanbanPriorityLevel { + CRITICAL = "critical", + HIGH = "high", + MEDIUM = "medium", + LOW = "low", + NONE = "none", } export enum TaskStatus { diff --git a/app/_types/index.ts b/app/_types/index.ts index 044a351b..31c455ff 100644 --- a/app/_types/index.ts +++ b/app/_types/index.ts @@ -2,6 +2,8 @@ export type { ItemType, Result, SharingPermissions } from "./core"; export type { ChecklistType, + KanbanPriority, + KanbanReminder, TimeEntry, RecurrenceRule, StatusChange, diff --git a/app/_utils/checklist-utils.ts b/app/_utils/checklist-utils.ts index 4194aaf3..821d2383 100644 --- a/app/_utils/checklist-utils.ts +++ b/app/_utils/checklist-utils.ts @@ -1,5 +1,5 @@ import path from "path"; -import { Item } from "@/app/_types"; +import { Item, KanbanPriority, KanbanReminder } from "@/app/_types"; import { Checklist, ChecklistType } from "@/app/_types"; import { ChecklistsTypes, TaskStatus } from "@/app/_types/enums"; import { @@ -16,7 +16,7 @@ import { import { extractHashtagsFromContent, normalizeTag } from "./tag-utils"; export const isItemCompleted = (item: Item, checklistType: string): boolean => { - if (checklistType === ChecklistsTypes.TASK) { + if (checklistType === ChecklistsTypes.KANBAN || checklistType === ChecklistsTypes.TASK) { return item.status === TaskStatus.COMPLETED || !!item.completed; } return !!item.completed; @@ -88,7 +88,7 @@ export const parseMarkdown = ( const type = extractChecklistType(content); const checklistType = - type === "task" ? ChecklistsTypes.TASK : ChecklistsTypes.SIMPLE; + type === "kanban" ? ChecklistsTypes.KANBAN : ChecklistsTypes.SIMPLE; const lines = contentWithoutMetadata.split("\n"); const itemLines = lines.filter( @@ -158,7 +158,7 @@ export const parseMarkdown = ( let item: Item; let recurrence = undefined; - if (type === "task" && text.includes(" | ")) { + if (type === "kanban" && text.includes(" | ")) { const parts = text.split(" | "); const itemText = parts[0].replace(/∣/g, "|"); const metadata = parts.slice(1); @@ -169,6 +169,10 @@ export const parseMarkdown = ( let targetDate: string | undefined; let description: string | undefined; let itemMetadata: Record = {}; + let priority: KanbanPriority | undefined; + let score: number | undefined; + let assignee: string | undefined; + let reminder: KanbanReminder | undefined; metadata.forEach((meta) => { if (meta.startsWith("status:")) { @@ -196,6 +200,18 @@ export const parseMarkdown = ( } } else if (meta.startsWith("recurrence:")) { recurrence = parseRecurrenceFromMarkdown([meta]); + } else if (meta.startsWith("priority:")) { + priority = meta.substring(9) as KanbanPriority; + } else if (meta.startsWith("score:")) { + score = parseInt(meta.substring(6)); + } else if (meta.startsWith("assignee:")) { + assignee = meta.substring(9); + } else if (meta.startsWith("reminder:")) { + try { + reminder = JSON.parse(meta.substring(9)); + } catch { + reminder = { datetime: meta.substring(9) }; + } } }); @@ -211,6 +227,10 @@ export const parseMarkdown = ( description, ...itemMetadata, ...(recurrence ? { recurrence } : {}), + ...(priority ? { priority } : {}), + ...(score !== undefined ? { score } : {}), + ...(assignee ? { assignee } : {}), + ...(reminder ? { reminder } : {}), }; } else { let itemText = text.replace(/∣/g, "|"); @@ -333,7 +353,7 @@ const generateItemMarkdown = ( const escapedText = item.text.replace(/\|/g, "∣"); let itemLine: string; - if (type === "task") { + if (type === "kanban" || type === "task") { const metadata: string[] = []; if (item.status && item.status !== TaskStatus.TODO) { @@ -387,6 +407,22 @@ const generateItemMarkdown = ( metadata.push(...recurrenceParts); } + if (item.priority && item.priority !== "none") { + metadata.push(`priority:${item.priority}`); + } + + if (item.score !== undefined) { + metadata.push(`score:${item.score}`); + } + + if (item.assignee) { + metadata.push(`assignee:${item.assignee}`); + } + + if (item.reminder) { + metadata.push(`reminder:${JSON.stringify(item.reminder)}`); + } + if (Object.keys(itemMetadata).length > 0) { metadata.push(`metadata:${JSON.stringify(itemMetadata)}`); } @@ -427,6 +463,18 @@ const generateItemMarkdown = ( if (item.targetDate) { itemMetadata.targetDate = item.targetDate; } + if (item.priority && item.priority !== "none") { + itemMetadata.priority = item.priority; + } + if (item.score !== undefined) { + itemMetadata.score = item.score; + } + if (item.assignee) { + itemMetadata.assignee = item.assignee; + } + if (item.reminder) { + itemMetadata.reminder = item.reminder; + } const metadata: string[] = []; @@ -462,7 +510,8 @@ export const listToMarkdown = (list: Checklist): string => { const metadata: any = {}; metadata.uuid = list.uuid || generateUuid(); metadata.title = list.title || "Untitled Checklist"; - if (list.type === ChecklistsTypes.TASK) metadata.checklistType = "task"; + if (list.type === ChecklistsTypes.KANBAN) metadata.checklistType = "kanban"; + else if (list.type === ChecklistsTypes.TASK) metadata.checklistType = "kanban"; else if (list.type === ChecklistsTypes.SIMPLE) metadata.checklistType = "simple"; diff --git a/app/_utils/client-parser-utils.ts b/app/_utils/client-parser-utils.ts index 5003655b..33e76b0b 100644 --- a/app/_utils/client-parser-utils.ts +++ b/app/_utils/client-parser-utils.ts @@ -4,6 +4,8 @@ import { Item, ChecklistType, KanbanStatus, + KanbanPriority, + KanbanReminder, } from "@/app/_types"; import { TaskStatus } from "@/app/_types/enums"; @@ -12,7 +14,7 @@ import { extractYamlMetadata } from "./yaml-metadata-utils"; export const parseChecklistContent = ( rawContent: string, - id: string + id: string, ): { title: string; items: Item[]; @@ -35,14 +37,14 @@ export const parseChecklistContent = ( const lines = contentWithoutMetadata.split("\n"); const itemLines = lines.filter( - (line) => line.trim().startsWith("- [") || /^\s*- \[/.test(line) + (line) => line.trim().startsWith("- [") || /^\s*- \[/.test(line), ); const buildNestedItems = ( lines: string[], startIndex: number = 0, parentLevel: number = 0, - itemIndex: number = 0 + itemIndex: number = 0, ): { items: Item[]; nextIndex: number } => { const items: Item[] = []; let currentIndex = startIndex; @@ -96,7 +98,7 @@ export const parseChecklistContent = ( let item: Item; let recurrence = undefined; - const isTask = checklistType === "task"; + const isTask = checklistType === "task" || checklistType === "kanban"; if (isTask && text.includes(" | ")) { const parts = text.split(" | "); @@ -108,7 +110,11 @@ export const parseChecklistContent = ( let estimatedTime: number | undefined; let targetDate: string | undefined; let description: string | undefined; - let itemMetadata: Record = {}; + let itemMetadata: Record = {}; + let priority: KanbanPriority | undefined; + let score: number | undefined; + let assignee: string | undefined; + let reminder: KanbanReminder | undefined; metadataParts.forEach((meta) => { if (meta.startsWith("status:")) { @@ -130,17 +136,33 @@ export const parseChecklistContent = ( description = meta.substring(12).replace(/∣/g, "|"); } else if (meta.startsWith("metadata:")) { try { - itemMetadata = JSON.parse(meta.substring(9)); + itemMetadata = JSON.parse(meta.substring(9)) as Record< + string, + unknown + >; } catch (e) { console.warn("Failed to parse item metadata:", e); } } else if (meta.startsWith("recurrence:")) { recurrence = parseRecurrenceFromMarkdown([meta]); + } else if (meta.startsWith("priority:")) { + priority = meta.substring(9) as KanbanPriority; + } else if (meta.startsWith("score:")) { + const parsed = parseInt(meta.substring(6), 10); + if (!Number.isNaN(parsed)) score = parsed; + } else if (meta.startsWith("assignee:")) { + assignee = meta.substring(9) || undefined; + } else if (meta.startsWith("reminder:")) { + try { + reminder = JSON.parse(meta.substring(9)) as KanbanReminder; + } catch { + reminder = { datetime: meta.substring(9) }; + } } }); item = { - id: itemMetadata.id || `${id}-${currentItemIndex}`, + id: (itemMetadata.id as string) || `${id}-${currentItemIndex}`, text: itemText, completed, order: currentItemIndex, @@ -151,6 +173,10 @@ export const parseChecklistContent = ( description, ...itemMetadata, ...(recurrence ? { recurrence } : {}), + ...(priority ? { priority } : {}), + ...(score !== undefined ? { score } : {}), + ...(assignee ? { assignee } : {}), + ...(reminder ? { reminder } : {}), }; } else { let itemText = text.replace(/∣/g, "|"); @@ -193,7 +219,7 @@ export const parseChecklistContent = ( lines, currentIndex + 1, parentLevel + 1, - 0 + 0, ); if (nestedResult.items.length > 0) { (item as any).children = nestedResult.items; @@ -234,8 +260,15 @@ export const parseChecklistContent = ( export const parseNoteContent = ( rawContent: string, - id: string -): { title: string; content: string; uuid?: string; encrypted?: boolean; encryptionMethod?: "pgp" | "xchacha"; tags?: string[] } => { + id: string, +): { + title: string; + content: string; + uuid?: string; + encrypted?: boolean; + encryptionMethod?: "pgp" | "xchacha"; + tags?: string[]; +} => { const { metadata, contentWithoutMetadata } = extractYamlMetadata(rawContent); const tags = Array.isArray(metadata.tags) ? metadata.tags : undefined; diff --git a/app/_utils/kanban-utils.tsx b/app/_utils/kanban-utils.tsx deleted file mode 100644 index 7e349de0..00000000 --- a/app/_utils/kanban-utils.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import { Clock01Icon, TimeQuarterIcon } from "hugeicons-react"; -import { TaskStatus } from "@/app/_types/enums"; - -import type { JSX } from "react"; - -export const formatTimerTime = (seconds: number): string => { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = seconds % 60; - - if (hours > 0) { - return `${hours}:${minutes.toString().padStart(2, "0")}:${secs - .toString() - .padStart(2, "0")}`; - } - return `${minutes}:${secs.toString().padStart(2, "0")}`; -}; - -export const getStatusColor = (status?: string): string => { - switch (status) { - case TaskStatus.TODO: - return "bg-background/50 border-border"; - case TaskStatus.IN_PROGRESS: - return "bg-primary/10 border-primary/30"; - case TaskStatus.COMPLETED: - return "bg-green-500/10 border-green-500/30"; - case TaskStatus.PAUSED: - return "bg-yellow-500/10 border-yellow-500/30"; - default: - return "bg-muted/50 border-border"; - } -}; - -export const getStatusIcon = (status?: string): JSX.Element | null => { - switch (status) { - case TaskStatus.IN_PROGRESS: - return ; - case TaskStatus.COMPLETED: - return ( - - ); - case TaskStatus.PAUSED: - return ( - - ); - default: - return null; - } -}; diff --git a/app/_utils/kanban/calendar-utils.ts b/app/_utils/kanban/calendar-utils.ts new file mode 100644 index 00000000..cb05f667 --- /dev/null +++ b/app/_utils/kanban/calendar-utils.ts @@ -0,0 +1,120 @@ +import { Item, KanbanStatus } from "@/app/_types"; + +export interface CalendarEvent { + id: string; + title: string; + date: string; + status?: string; + priority?: string; + completed: boolean; + itemId: string; +} + +export const parseItemsForCalendar = (items: Item[]): CalendarEvent[] => + items + .filter((item) => item.targetDate && !item.isArchived) + .map((item) => ({ + id: item.id, + title: item.text, + date: item.targetDate!, + status: item.status, + priority: item.priority, + completed: item.completed, + itemId: item.id, + })); + +const _escapeICS = (text: string): string => + text.replace(/\\/g, "\\\\").replace(/;/g, "\\;").replace(/,/g, "\\,").replace(/\n/g, "\\n"); + +const _formatICSDate = (dateStr: string): string => { + const d = new Date(dateStr); + return d.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, ""); +}; + +export const generateVEVENT = (item: Item, boardTitle: string): string => { + if (!item.targetDate) return ""; + + const dtstart = _formatICSDate(item.targetDate); + const dtend = _formatICSDate( + new Date(new Date(item.targetDate).getTime() + 3600000).toISOString() + ); + const now = _formatICSDate(new Date().toISOString()); + + const lines = [ + "BEGIN:VEVENT", + `UID:${item.id}@jotty`, + `DTSTAMP:${now}`, + `DTSTART:${dtstart}`, + `DTEND:${dtend}`, + `SUMMARY:${_escapeICS(item.text)}`, + `DESCRIPTION:${_escapeICS(`Board: ${boardTitle}${item.description ? `\\n${item.description}` : ""}`)}`, + item.status ? `STATUS:${item.completed ? "COMPLETED" : "NEEDS-ACTION"}` : "", + "END:VEVENT", + ]; + + return lines.filter(Boolean).join("\r\n"); +}; + +export const generateICS = (items: Item[], boardTitle: string): string => { + const events = items + .filter((item) => item.targetDate && !item.isArchived) + .map((item) => generateVEVENT(item, boardTitle)) + .filter(Boolean); + + return [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//Jotty//Kanban//EN", + `X-WR-CALNAME:${_escapeICS(boardTitle)}`, + "CALSCALE:GREGORIAN", + "METHOD:PUBLISH", + ...events, + "END:VCALENDAR", + ].join("\r\n"); +}; + +export const getItemsGroupedByDate = (items: Item[]): Record => { + const grouped: Record = {}; + + items + .filter((item) => item.targetDate && !item.isArchived) + .forEach((item) => { + const date = item.targetDate!.split("T")[0]; + if (!grouped[date]) grouped[date] = []; + grouped[date].push(item); + }); + + return grouped; +}; + +export const getDaysInMonth = (year: number, month: number): Date[] => { + const days: Date[] = []; + const date = new Date(year, month, 1); + while (date.getMonth() === month) { + days.push(new Date(date)); + date.setDate(date.getDate() + 1); + } + return days; +}; + +export const getCalendarGrid = (year: number, month: number): (Date | null)[][] => { + const days = getDaysInMonth(year, month); + const firstDay = days[0].getDay(); + const grid: (Date | null)[][] = []; + let week: (Date | null)[] = new Array(firstDay).fill(null); + + days.forEach((day) => { + week.push(day); + if (week.length === 7) { + grid.push(week); + week = []; + } + }); + + if (week.length > 0) { + while (week.length < 7) week.push(null); + grid.push(week); + } + + return grid; +}; diff --git a/app/_utils/kanban/index.tsx b/app/_utils/kanban/index.tsx new file mode 100644 index 00000000..22d9544e --- /dev/null +++ b/app/_utils/kanban/index.tsx @@ -0,0 +1,82 @@ +import { Clock01Icon, TimeQuarterIcon } from "hugeicons-react"; +import { TaskStatus } from "@/app/_types/enums"; +import { Item, KanbanPriority } from "@/app/_types"; + +import type { JSX } from "react"; + +export const formatTimerTime = (seconds: number): string => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours}:${minutes.toString().padStart(2, "0")}:${secs + .toString() + .padStart(2, "0")}`; + } + return `${minutes}:${secs.toString().padStart(2, "0")}`; +}; + +export const getStatusColor = (status?: string, customColor?: string): string => { + if (customColor) { + return `bg-[${customColor}]/10 border-[${customColor}]/30`; + } + + switch (status) { + case TaskStatus.TODO: + return "bg-background/50 border-border"; + case TaskStatus.IN_PROGRESS: + return "bg-primary/10 border-primary/30"; + case TaskStatus.COMPLETED: + return "bg-green-500/10 border-green-500/30"; + case TaskStatus.PAUSED: + return "bg-yellow-500/10 border-yellow-500/30"; + default: + return "bg-muted/50 border-border"; + } +}; + +export const getStatusIcon = (status?: string): JSX.Element | null => { + switch (status) { + case TaskStatus.IN_PROGRESS: + return ; + case TaskStatus.COMPLETED: + return ( + + ); + case TaskStatus.PAUSED: + return ( + + ); + default: + return null; + } +}; + +const PRIORITY_CONFIG: Record = { + critical: { color: "text-red-600 bg-red-100 dark:bg-red-900/30 dark:text-red-400", translationKey: "kanban.critical", sortOrder: 0 }, + high: { color: "text-orange-600 bg-orange-100 dark:bg-orange-900/30 dark:text-orange-400", translationKey: "kanban.high", sortOrder: 1 }, + medium: { color: "text-yellow-600 bg-yellow-100 dark:bg-yellow-900/30 dark:text-yellow-400", translationKey: "kanban.medium", sortOrder: 2 }, + low: { color: "text-blue-600 bg-blue-100 dark:bg-blue-900/30 dark:text-blue-400", translationKey: "kanban.low", sortOrder: 3 }, + none: { color: "text-muted-foreground bg-muted", translationKey: "kanban.none", sortOrder: 4 }, +}; + +export const getPriorityColor = (priority?: KanbanPriority): string => + PRIORITY_CONFIG[priority || "none"].color; + +export const getPriorityLabel = (priority: KanbanPriority | undefined, t: (key: string) => string): string => + t(PRIORITY_CONFIG[priority || "none"].translationKey); + +export const _getPrioritySortOrder = (priority?: KanbanPriority): number => + PRIORITY_CONFIG[priority || "none"].sortOrder; + +export const sortByPriority = (items: Item[]): Item[] => + [...items].sort((a, b) => + _getPrioritySortOrder(a.priority) - _getPrioritySortOrder(b.priority) + ); + +export const calculatePriorityScore = (item: Item): number => { + const priorityWeight = 5 - _getPrioritySortOrder(item.priority); + const scoreWeight = item.score ?? 0; + return priorityWeight * 10 + scoreWeight; +}; diff --git a/app/_utils/kanban/reminder-utils.ts b/app/_utils/kanban/reminder-utils.ts new file mode 100644 index 00000000..457763ee --- /dev/null +++ b/app/_utils/kanban/reminder-utils.ts @@ -0,0 +1,39 @@ +import { Item } from "@/app/_types"; + +export const getDueReminders = (items: Item[]): Item[] => + items.filter((item) => { + if (!item.reminder || item.reminder.notified || item.isArchived) return false; + return new Date(item.reminder.datetime) <= new Date(); + }); + +export const formatReminderTime = (datetime: string): string => { + const date = new Date(datetime); + const now = new Date(); + const diff = date.getTime() - now.getTime(); + + if (diff < 0) return "Overdue"; + if (diff < 60000) return "Due now"; + if (diff < 3600000) return `In ${Math.ceil(diff / 60000)}m`; + if (diff < 86400000) return `In ${Math.ceil(diff / 3600000)}h`; + return date.toLocaleDateString(); +}; + +export const isOverdue = (item: Item): boolean => { + if (!item.targetDate) return false; + return new Date(item.targetDate) < new Date() && !item.completed; +}; + +export const isDueToday = (item: Item): boolean => { + if (!item.targetDate) return false; + const today = new Date().toISOString().split("T")[0]; + return item.targetDate.startsWith(today) && !item.completed; +}; + +export const isDueThisWeek = (item: Item): boolean => { + if (!item.targetDate) return false; + const now = new Date(); + const weekEnd = new Date(now); + weekEnd.setDate(weekEnd.getDate() + (7 - weekEnd.getDay())); + const target = new Date(item.targetDate); + return target >= now && target <= weekEnd && !item.completed; +}; diff --git a/app/_utils/yaml-metadata-utils.ts b/app/_utils/yaml-metadata-utils.ts index e3eb9932..cf9fc0c5 100644 --- a/app/_utils/yaml-metadata-utils.ts +++ b/app/_utils/yaml-metadata-utils.ts @@ -14,7 +14,7 @@ export const toIso = ( export interface DocumentMetadata { uuid?: string; title?: string; - checklistType?: "task" | "simple"; + checklistType?: "task" | "simple" | "kanban"; [key: string]: any; } @@ -112,15 +112,18 @@ export const extractTitle = (content: string, filename?: string): string => { return "Untitled"; }; -export const extractChecklistType = (content: string): "task" | "simple" => { +export const extractChecklistType = (content: string): "kanban" | "simple" => { const { metadata, contentWithoutMetadata } = extractYamlMetadata(content); if (metadata.checklistType) { - return metadata.checklistType; + if (metadata.checklistType === "task" || metadata.checklistType === "kanban") { + return "kanban"; + } + return "simple"; } if (contentWithoutMetadata.includes("")) { - return "task"; + return "kanban"; } return "simple"; diff --git a/app/api/checklists/route.ts b/app/api/checklists/route.ts index 33da8e67..028b056c 100644 --- a/app/api/checklists/route.ts +++ b/app/api/checklists/route.ts @@ -46,7 +46,7 @@ export async function GET(request: NextRequest) { completed: item.completed, }; - if (listType === "task") { + if (listType === "task" || listType === "kanban") { baseItem.status = item.status || TaskStatus.TODO; baseItem.time = item.timeEntries && item.timeEntries.length > 0 @@ -101,9 +101,9 @@ export async function POST(request: NextRequest) { ); } - if (type !== "simple" && type !== "task") { + if (type !== "simple" && type !== "task" && type !== "kanban") { return NextResponse.json( - { error: "Type must be 'simple' or 'task'" }, + { error: "Type must be 'simple', 'task' or 'kanban'" }, { status: 400 } ); } diff --git a/app/api/kanban/[boardId]/calendar/route.ts b/app/api/kanban/[boardId]/calendar/route.ts new file mode 100644 index 00000000..43176b5e --- /dev/null +++ b/app/api/kanban/[boardId]/calendar/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withApiAuth } from "@/app/_utils/api-utils"; +import { getListById } from "@/app/_server/actions/checklist"; +import { generateICS, parseItemsForCalendar } from "@/app/_utils/kanban/calendar-utils"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest, props: { params: Promise<{ boardId: string }> }) { + const params = await props.params; + return withApiAuth(request, async (user) => { + try { + const board = await getListById(params.boardId, user.username); + if (!board) { + return NextResponse.json({ error: "Board not found" }, { status: 404 }); + } + + if (board.type !== "kanban" && board.type !== "task") { + return NextResponse.json({ error: "Not a kanban board" }, { status: 400 }); + } + + const accept = request.headers.get("accept") || ""; + + if (accept.includes("text/calendar")) { + const ics = generateICS(board.items, board.title || "Kanban Board"); + return new NextResponse(ics, { + headers: { + "Content-Type": "text/calendar; charset=utf-8", + "Content-Disposition": `attachment; filename="${board.title || "board"}.ics"`, + }, + }); + } + + const events = parseItemsForCalendar(board.items); + return NextResponse.json({ events }); + } catch (error) { + console.error("API Error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } + }); +} diff --git a/app/api/kanban/[boardId]/items/[itemId]/assign/route.ts b/app/api/kanban/[boardId]/items/[itemId]/assign/route.ts new file mode 100644 index 00000000..4266ead0 --- /dev/null +++ b/app/api/kanban/[boardId]/items/[itemId]/assign/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withApiAuth } from "@/app/_utils/api-utils"; +import { getListById } from "@/app/_server/actions/checklist"; +import { assignKanbanItem } from "@/app/_server/actions/kanban/items"; + +export const dynamic = "force-dynamic"; + +export async function PUT( + request: NextRequest, + props: { params: Promise<{ boardId: string; itemId: string }> } +) { + const params = await props.params; + return withApiAuth(request, async (user) => { + try { + const body = await request.json(); + const { assignee } = body; + + const board = await getListById(params.boardId, user.username); + if (!board) { + return NextResponse.json({ error: "Board not found" }, { status: 404 }); + } + + if (board.type !== "kanban" && board.type !== "task") { + return NextResponse.json({ error: "Not a kanban board" }, { status: 400 }); + } + + const formData = new FormData(); + formData.append("listId", board.id); + formData.append("itemId", params.itemId); + formData.append("assignee", assignee || ""); + formData.append("category", board.category || "Uncategorized"); + + const result = await assignKanbanItem(formData); + + if (result.error) { + return NextResponse.json( + { error: result.error }, + { status: 400 } + ); + } + + return NextResponse.json({ success: true, data: result.data }); + } catch (error) { + console.error("API Error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } + }); +} diff --git a/app/api/kanban/[boardId]/items/[itemId]/reminder/route.ts b/app/api/kanban/[boardId]/items/[itemId]/reminder/route.ts new file mode 100644 index 00000000..a08aeb5d --- /dev/null +++ b/app/api/kanban/[boardId]/items/[itemId]/reminder/route.ts @@ -0,0 +1,100 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withApiAuth } from "@/app/_utils/api-utils"; +import { getListById } from "@/app/_server/actions/checklist"; +import { setKanbanItemReminder } from "@/app/_server/actions/kanban/items"; + +export const dynamic = "force-dynamic"; + +export async function PUT( + request: NextRequest, + props: { params: Promise<{ boardId: string; itemId: string }> } +) { + const params = await props.params; + return withApiAuth(request, async (user) => { + try { + const body = await request.json(); + const { datetime } = body; + + if (!datetime) { + return NextResponse.json( + { error: "Datetime is required" }, + { status: 400 } + ); + } + + const board = await getListById(params.boardId, user.username); + if (!board) { + return NextResponse.json({ error: "Board not found" }, { status: 404 }); + } + + if (board.type !== "kanban" && board.type !== "task") { + return NextResponse.json({ error: "Not a kanban board" }, { status: 400 }); + } + + const formData = new FormData(); + formData.append("listId", board.id); + formData.append("itemId", params.itemId); + formData.append("reminder", JSON.stringify({ datetime })); + formData.append("category", board.category || "Uncategorized"); + + const result = await setKanbanItemReminder(formData); + + if (result.error) { + return NextResponse.json( + { error: result.error }, + { status: 400 } + ); + } + + return NextResponse.json({ success: true, data: result.data }); + } catch (error) { + console.error("API Error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } + }); +} + +export async function DELETE( + request: NextRequest, + props: { params: Promise<{ boardId: string; itemId: string }> } +) { + const params = await props.params; + return withApiAuth(request, async (user) => { + try { + const board = await getListById(params.boardId, user.username); + if (!board) { + return NextResponse.json({ error: "Board not found" }, { status: 404 }); + } + + if (board.type !== "kanban" && board.type !== "task") { + return NextResponse.json({ error: "Not a kanban board" }, { status: 400 }); + } + + const formData = new FormData(); + formData.append("listId", board.id); + formData.append("itemId", params.itemId); + formData.append("reminder", ""); + formData.append("category", board.category || "Uncategorized"); + + const result = await setKanbanItemReminder(formData); + + if (result.error) { + return NextResponse.json( + { error: result.error }, + { status: 400 } + ); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("API Error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } + }); +} diff --git a/app/api/kanban/[boardId]/items/[itemId]/route.ts b/app/api/kanban/[boardId]/items/[itemId]/route.ts new file mode 100644 index 00000000..eef8cdf0 --- /dev/null +++ b/app/api/kanban/[boardId]/items/[itemId]/route.ts @@ -0,0 +1,96 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withApiAuth } from "@/app/_utils/api-utils"; +import { getListById } from "@/app/_server/actions/checklist"; +import { updateItem, deleteItem } from "@/app/_server/actions/checklist-item"; + +export const dynamic = "force-dynamic"; + +export async function PUT( + request: NextRequest, + props: { params: Promise<{ boardId: string; itemId: string }> } +) { + const params = await props.params; + return withApiAuth(request, async (user) => { + try { + const body = await request.json(); + const { text, priority, score, assignee, reminder } = body; + + const board = await getListById(params.boardId, user.username); + if (!board) { + return NextResponse.json({ error: "Board not found" }, { status: 404 }); + } + + if (board.type !== "kanban" && board.type !== "task") { + return NextResponse.json({ error: "Not a kanban board" }, { status: 400 }); + } + + const formData = new FormData(); + formData.append("listId", board.id); + formData.append("itemId", params.itemId); + formData.append("category", board.category || "Uncategorized"); + if (text !== undefined) formData.append("text", text); + if (priority !== undefined) formData.append("priority", priority); + if (score !== undefined) formData.append("score", String(score)); + if (assignee !== undefined) formData.append("assignee", assignee); + if (reminder !== undefined) formData.append("reminder", JSON.stringify(reminder)); + + const result = await updateItem(board, formData, user.username); + + if (!result.success) { + return NextResponse.json( + { error: result.error || "Failed to update item" }, + { status: 400 } + ); + } + + return NextResponse.json({ success: true, data: result.data }); + } catch (error) { + console.error("API Error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } + }); +} + +export async function DELETE( + request: NextRequest, + props: { params: Promise<{ boardId: string; itemId: string }> } +) { + const params = await props.params; + return withApiAuth(request, async (user) => { + try { + const board = await getListById(params.boardId, user.username); + if (!board) { + return NextResponse.json({ error: "Board not found" }, { status: 404 }); + } + + if (board.type !== "kanban" && board.type !== "task") { + return NextResponse.json({ error: "Not a kanban board" }, { status: 400 }); + } + + const formData = new FormData(); + formData.append("listId", board.id); + formData.append("itemId", params.itemId); + formData.append("category", board.category || "Uncategorized"); + + const result = await deleteItem(formData); + + if (!result.success) { + return NextResponse.json( + { error: result.error || "Failed to delete item" }, + { status: 400 } + ); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("API Error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } + }); +} diff --git a/app/api/kanban/[boardId]/items/[itemId]/status/route.ts b/app/api/kanban/[boardId]/items/[itemId]/status/route.ts new file mode 100644 index 00000000..3cf90915 --- /dev/null +++ b/app/api/kanban/[boardId]/items/[itemId]/status/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withApiAuth } from "@/app/_utils/api-utils"; +import { getListById } from "@/app/_server/actions/checklist"; +import { updateItemStatus } from "@/app/_server/actions/checklist-item"; + +export const dynamic = "force-dynamic"; + +export async function PUT( + request: NextRequest, + props: { params: Promise<{ boardId: string; itemId: string }> } +) { + const params = await props.params; + return withApiAuth(request, async (user) => { + try { + const body = await request.json(); + const { status } = body; + + if (!status) { + return NextResponse.json( + { error: "Status is required" }, + { status: 400 } + ); + } + + const board = await getListById(params.boardId, user.username); + if (!board) { + return NextResponse.json({ error: "Board not found" }, { status: 404 }); + } + + if (board.type !== "kanban" && board.type !== "task") { + return NextResponse.json({ error: "Not a kanban board" }, { status: 400 }); + } + + const formData = new FormData(); + formData.append("listId", board.id); + formData.append("itemId", params.itemId); + formData.append("status", status); + formData.append("category", board.category || "Uncategorized"); + formData.append("username", user.username); + + const result = await updateItemStatus(formData, user.username); + + if (!result.success) { + return NextResponse.json( + { error: result.error || "Failed to update status" }, + { status: 400 } + ); + } + + return NextResponse.json({ success: true, data: result.data }); + } catch (error) { + console.error("API Error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } + }); +} diff --git a/app/api/kanban/[boardId]/items/route.ts b/app/api/kanban/[boardId]/items/route.ts new file mode 100644 index 00000000..3a8a0540 --- /dev/null +++ b/app/api/kanban/[boardId]/items/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withApiAuth } from "@/app/_utils/api-utils"; +import { getListById } from "@/app/_server/actions/checklist"; +import { createItem } from "@/app/_server/actions/checklist-item"; + +export const dynamic = "force-dynamic"; + +export async function POST(request: NextRequest, props: { params: Promise<{ boardId: string }> }) { + const params = await props.params; + return withApiAuth(request, async (user) => { + try { + const body = await request.json(); + const { text, status, description } = body; + + if (!text) { + return NextResponse.json( + { error: "Text is required" }, + { status: 400 } + ); + } + + const board = await getListById(params.boardId, user.username); + if (!board) { + return NextResponse.json({ error: "Board not found" }, { status: 404 }); + } + + if (board.type !== "kanban" && board.type !== "task") { + return NextResponse.json({ error: "Not a kanban board" }, { status: 400 }); + } + + const formData = new FormData(); + formData.append("listId", board.id); + formData.append("text", text); + formData.append("category", board.category || "Uncategorized"); + if (status) formData.append("status", status); + if (description) formData.append("description", description); + + const result = await createItem(board, formData, user.username); + + if (!result.success || !result.data) { + return NextResponse.json( + { error: result.error || "Failed to create item" }, + { status: 400 } + ); + } + + return NextResponse.json({ success: true, data: result.data }); + } catch (error) { + console.error("API Error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Internal server error" }, + { status: 500 } + ); + } + }); +} diff --git a/app/api/kanban/[boardId]/route.ts b/app/api/kanban/[boardId]/route.ts new file mode 100644 index 00000000..2b3a1a99 --- /dev/null +++ b/app/api/kanban/[boardId]/route.ts @@ -0,0 +1,152 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withApiAuth } from "@/app/_utils/api-utils"; +import { getListById, updateList, deleteList } from "@/app/_server/actions/checklist"; +import { TaskStatus } from "@/app/_types/enums"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest, props: { params: Promise<{ boardId: string }> }) { + const params = await props.params; + return withApiAuth(request, async (user) => { + try { + const board = await getListById(params.boardId, user.username); + if (!board) { + return NextResponse.json({ error: "Board not found" }, { status: 404 }); + } + + if (board.type !== "kanban" && board.type !== "task") { + return NextResponse.json({ error: "Not a kanban board" }, { status: 400 }); + } + + const transformItem = (item: any, index: number): any => { + const baseItem: any = { + id: item.id, + index, + text: item.text, + status: item.status || TaskStatus.TODO, + completed: item.completed, + priority: item.priority, + score: item.score, + assignee: item.assignee, + reminder: item.reminder, + }; + + if (item.children && item.children.length > 0) { + baseItem.children = item.children.map((child: any, childIndex: number) => + transformItem(child, childIndex) + ); + } + + return baseItem; + }; + + const transformedBoard = { + id: board.uuid || board.id, + title: board.title, + category: board.category || "Uncategorized", + statuses: board.statuses || [ + { id: "todo", name: "To Do", order: 0 }, + { id: "in_progress", name: "In Progress", order: 1 }, + { id: "completed", name: "Completed", order: 2 }, + ], + items: board.items.map((item, index) => transformItem(item, index)), + createdAt: board.createdAt, + updatedAt: board.updatedAt, + }; + + return NextResponse.json({ board: transformedBoard }); + } catch (error) { + console.error("API Error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } + }); +} + +export async function PUT(request: NextRequest, props: { params: Promise<{ boardId: string }> }) { + const params = await props.params; + return withApiAuth(request, async (user) => { + try { + const body = await request.json(); + const { title, category } = body; + + const board = await getListById(params.boardId, user.username); + if (!board) { + return NextResponse.json({ error: "Board not found" }, { status: 404 }); + } + + if (board.type !== "kanban" && board.type !== "task") { + return NextResponse.json({ error: "Not a kanban board" }, { status: 400 }); + } + + const formData = new FormData(); + formData.append("id", board.id); + formData.append("title", title ?? board.title); + formData.append("category", category ?? board.category ?? "Uncategorized"); + formData.append("originalCategory", board.category || "Uncategorized"); + formData.append("apiUser", JSON.stringify(user)); + + const result = await updateList(formData); + if (result.error) { + return NextResponse.json({ error: result.error }, { status: 400 }); + } + + const transformedBoard = { + id: result.data?.uuid || result.data?.id, + title: result.data?.title, + category: result.data?.category || "Uncategorized", + statuses: result.data?.statuses || [ + { id: "todo", name: "To Do", order: 0 }, + { id: "in_progress", name: "In Progress", order: 1 }, + { id: "completed", name: "Completed", order: 2 }, + ], + createdAt: result.data?.createdAt, + updatedAt: result.data?.updatedAt, + }; + + return NextResponse.json({ success: true, data: transformedBoard }); + } catch (error) { + console.error("API Error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } + }); +} + +export async function DELETE(request: NextRequest, props: { params: Promise<{ boardId: string }> }) { + const params = await props.params; + return withApiAuth(request, async (user) => { + try { + const board = await getListById(params.boardId, user.username); + if (!board) { + return NextResponse.json({ error: "Board not found" }, { status: 404 }); + } + + if (board.type !== "kanban" && board.type !== "task") { + return NextResponse.json({ error: "Not a kanban board" }, { status: 400 }); + } + + const formData = new FormData(); + formData.append("id", board.id); + formData.append("category", board.category || "Uncategorized"); + formData.append("apiUser", JSON.stringify(user)); + + const result = await deleteList(formData); + if (result.error) { + return NextResponse.json({ error: result.error }, { status: 400 }); + } + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("API Error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } + }); +} diff --git a/app/api/kanban/[boardId]/statuses/route.ts b/app/api/kanban/[boardId]/statuses/route.ts new file mode 100644 index 00000000..f10f7671 --- /dev/null +++ b/app/api/kanban/[boardId]/statuses/route.ts @@ -0,0 +1,54 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withApiAuth } from "@/app/_utils/api-utils"; +import { getListById, updateChecklistStatuses } from "@/app/_server/actions/checklist"; + +export const dynamic = "force-dynamic"; + +export async function PUT(request: NextRequest, props: { params: Promise<{ boardId: string }> }) { + const params = await props.params; + return withApiAuth(request, async (user) => { + try { + const body = await request.json(); + const { statuses } = body; + + if (!statuses || !Array.isArray(statuses)) { + return NextResponse.json( + { error: "Statuses array is required" }, + { status: 400 } + ); + } + + const board = await getListById(params.boardId, user.username); + if (!board) { + return NextResponse.json({ error: "Board not found" }, { status: 404 }); + } + + if (board.type !== "kanban" && board.type !== "task") { + return NextResponse.json({ error: "Not a kanban board" }, { status: 400 }); + } + + const formData = new FormData(); + formData.append("id", board.id); + formData.append("statuses", JSON.stringify(statuses)); + formData.append("category", board.category || "Uncategorized"); + formData.append("apiUser", JSON.stringify(user)); + + const result = await updateChecklistStatuses(formData); + + if (result.error) { + return NextResponse.json( + { error: result.error }, + { status: 400 } + ); + } + + return NextResponse.json({ success: true, data: result.data }); + } catch (error) { + console.error("API Error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } + }); +} diff --git a/app/api/kanban/route.ts b/app/api/kanban/route.ts new file mode 100644 index 00000000..cf8a61d0 --- /dev/null +++ b/app/api/kanban/route.ts @@ -0,0 +1,149 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withApiAuth } from "@/app/_utils/api-utils"; +import { getUserChecklists, createList } from "@/app/_server/actions/checklist"; +import { TaskStatus } from "@/app/_types/enums"; +import { Checklist, Result } from "@/app/_types"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: NextRequest) { + return withApiAuth(request, async (user) => { + try { + const { searchParams } = new URL(request.url); + const category = searchParams.get('category'); + const status = searchParams.get('status'); + const search = searchParams.get('q'); + + const lists = await getUserChecklists({ username: user.username }) as Result; + if (!lists.success || !lists.data) { + return NextResponse.json( + { error: lists.error || "Failed to fetch boards" }, + { status: 500 } + ); + } + + let boards = lists.data.filter( + (list) => list.owner === user.username && (list.type === "kanban" || list.type === "task") + ); + + if (category) { + boards = boards.filter((list) => list.category === category); + } + if (status) { + boards = boards.filter((list) => + list.items.some(item => item.status === status) + ); + } + if (search) { + const searchLower = search.toLowerCase(); + boards = boards.filter((list) => + list.title?.toLowerCase().includes(searchLower) || + list.items.some(item => item.text.toLowerCase().includes(searchLower)) + ); + } + + const transformItem = (item: any, index: number): any => { + const baseItem: any = { + id: item.id, + index, + text: item.text, + status: item.status || TaskStatus.TODO, + completed: item.completed, + priority: item.priority, + score: item.score, + assignee: item.assignee, + reminder: item.reminder, + }; + + if (item.children && item.children.length > 0) { + baseItem.children = item.children.map((child: any, childIndex: number) => + transformItem(child, childIndex) + ); + } + + return baseItem; + }; + + const data = boards.map((list) => ({ + id: list.uuid || list.id, + title: list.title, + category: list.category || "Uncategorized", + statuses: list.statuses || [ + { id: "todo", name: "To Do", order: 0 }, + { id: "in_progress", name: "In Progress", order: 1 }, + { id: "completed", name: "Completed", order: 2 }, + ], + items: list.items.map((item, index) => transformItem(item, index)), + createdAt: list.createdAt, + updatedAt: list.updatedAt, + })); + + return NextResponse.json({ boards: data }); + } catch (error) { + return NextResponse.json( + { + error: + error instanceof Error + ? error.message + : "Failed to fetch boards", + }, + { status: 500 } + ); + } + }); +} + +export async function POST(request: NextRequest) { + return withApiAuth(request, async (user) => { + try { + const body = await request.json(); + const { title, category = "Uncategorized", statuses } = body; + + if (!title) { + return NextResponse.json( + { error: "Title is required" }, + { status: 400 } + ); + } + + const formData = new FormData(); + formData.append("title", title); + formData.append("category", category); + formData.append("type", "kanban"); + formData.append("user", JSON.stringify(user)); + + if (statuses) { + formData.append("statuses", JSON.stringify(statuses)); + } + + const result = await createList(formData); + + if (result.error || !result.data) { + console.error("Create board error:", result.error); + return NextResponse.json({ error: result.error || "Failed to create board" }, { status: 400 }); + } + + const transformedBoard = { + id: result.data?.uuid || result.data?.id, + title: result.data?.title, + category: result.data?.category || "Uncategorized", + statuses: result.data?.statuses || [ + { id: "todo", name: "To Do", order: 0 }, + { id: "in_progress", name: "In Progress", order: 1 }, + { id: "completed", name: "Completed", order: 2 }, + ], + items: [], + createdAt: result.data?.createdAt, + updatedAt: result.data?.updatedAt, + }; + + return NextResponse.json({ success: true, data: transformedBoard }); + } catch (error) { + console.error("API Error:", error); + return NextResponse.json( + { error: error instanceof Error ? error.message : "Internal server error" }, + { status: 500 } + ); + } + }); +} diff --git a/app/api/summary/route.ts b/app/api/summary/route.ts index 48b16c4d..14d77184 100644 --- a/app/api/summary/route.ts +++ b/app/api/summary/route.ts @@ -65,7 +65,7 @@ export async function GET(request: NextRequest) { completedItems++; } - if (list.type === "task" && item.status) { + if ((list.type === "kanban" || list.type === "task") && item.status) { totalTasks++; switch (item.status) { case TaskStatus.COMPLETED: diff --git a/app/api/tasks/[taskId]/items/[itemIndex]/route.ts b/app/api/tasks/[taskId]/items/[itemIndex]/route.ts index 507b2c1f..164ba722 100644 --- a/app/api/tasks/[taskId]/items/[itemIndex]/route.ts +++ b/app/api/tasks/[taskId]/items/[itemIndex]/route.ts @@ -21,7 +21,7 @@ export async function DELETE( return NextResponse.json({ error: "Task not found" }, { status: 404 }); } - if (task.type !== "task") { + if (task.type !== "kanban" && task.type !== "task") { return NextResponse.json({ error: "Not a task checklist" }, { status: 400 }); } diff --git a/app/api/tasks/[taskId]/items/[itemIndex]/status/route.ts b/app/api/tasks/[taskId]/items/[itemIndex]/status/route.ts index 63d6d709..aa107e07 100644 --- a/app/api/tasks/[taskId]/items/[itemIndex]/status/route.ts +++ b/app/api/tasks/[taskId]/items/[itemIndex]/status/route.ts @@ -27,7 +27,7 @@ export async function PUT( return NextResponse.json({ error: "Task not found" }, { status: 404 }); } - if (task.type !== "task") { + if (task.type !== "kanban" && task.type !== "task") { return NextResponse.json( { error: "Not a task checklist" }, { status: 400 } diff --git a/app/api/tasks/[taskId]/items/route.ts b/app/api/tasks/[taskId]/items/route.ts index 71d51131..a8fb6354 100644 --- a/app/api/tasks/[taskId]/items/route.ts +++ b/app/api/tasks/[taskId]/items/route.ts @@ -30,7 +30,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ task return NextResponse.json({ error: "Task not found" }, { status: 404 }); } - if (task.type !== "task") { + if (task.type !== "kanban" && task.type !== "task") { return NextResponse.json({ error: "Not a task checklist" }, { status: 400 }); } diff --git a/app/api/tasks/[taskId]/route.ts b/app/api/tasks/[taskId]/route.ts index 762f1278..15fd78ae 100644 --- a/app/api/tasks/[taskId]/route.ts +++ b/app/api/tasks/[taskId]/route.ts @@ -14,7 +14,7 @@ export async function GET(request: NextRequest, props: { params: Promise<{ taskI return NextResponse.json({ error: "Task not found" }, { status: 404 }); } - if (task.type !== "task") { + if (task.type !== "kanban" && task.type !== "task") { return NextResponse.json({ error: "Not a task checklist" }, { status: 400 }); } @@ -73,7 +73,7 @@ export async function PUT(request: NextRequest, props: { params: Promise<{ taskI return NextResponse.json({ error: "Task not found" }, { status: 404 }); } - if (task.type !== "task") { + if (task.type !== "kanban" && task.type !== "task") { return NextResponse.json({ error: "Not a task checklist" }, { status: 400 }); } @@ -122,7 +122,7 @@ export async function DELETE(request: NextRequest, props: { params: Promise<{ ta return NextResponse.json({ error: "Task not found" }, { status: 404 }); } - if (task.type !== "task") { + if (task.type !== "kanban" && task.type !== "task") { return NextResponse.json({ error: "Not a task checklist" }, { status: 400 }); } diff --git a/app/api/tasks/[taskId]/statuses/[statusId]/route.ts b/app/api/tasks/[taskId]/statuses/[statusId]/route.ts index a4b14202..caaccb84 100644 --- a/app/api/tasks/[taskId]/statuses/[statusId]/route.ts +++ b/app/api/tasks/[taskId]/statuses/[statusId]/route.ts @@ -24,7 +24,7 @@ export async function PUT( return NextResponse.json({ error: "Task not found" }, { status: 404 }); } - if (task.type !== "task") { + if (task.type !== "kanban" && task.type !== "task") { return NextResponse.json({ error: "Not a task checklist" }, { status: 400 }); } @@ -93,7 +93,7 @@ export async function DELETE( return NextResponse.json({ error: "Task not found" }, { status: 404 }); } - if (task.type !== "task") { + if (task.type !== "kanban" && task.type !== "task") { return NextResponse.json({ error: "Not a task checklist" }, { status: 400 }); } diff --git a/app/api/tasks/[taskId]/statuses/route.ts b/app/api/tasks/[taskId]/statuses/route.ts index 27f437c4..cfe345bd 100644 --- a/app/api/tasks/[taskId]/statuses/route.ts +++ b/app/api/tasks/[taskId]/statuses/route.ts @@ -19,7 +19,7 @@ export async function GET(request: NextRequest, props: { params: Promise<{ taskI return NextResponse.json({ error: "Task not found" }, { status: 404 }); } - if (task.type !== "task") { + if (task.type !== "kanban" && task.type !== "task") { return NextResponse.json( { error: "Not a task checklist" }, { status: 400 } @@ -62,7 +62,7 @@ export async function POST(request: NextRequest, props: { params: Promise<{ task return NextResponse.json({ error: "Task not found" }, { status: 404 }); } - if (task.type !== "task") { + if (task.type !== "kanban" && task.type !== "task") { return NextResponse.json( { error: "Not a task checklist" }, { status: 400 } diff --git a/app/api/tasks/route.ts b/app/api/tasks/route.ts index 64bd3330..a97a6563 100644 --- a/app/api/tasks/route.ts +++ b/app/api/tasks/route.ts @@ -23,7 +23,7 @@ export async function GET(request: NextRequest) { } let userTasks = lists.data.filter( - (list) => list.owner === user.username && list.type === "task" + (list) => list.owner === user.username && (list.type === "kanban" || list.type === "task") ); if (category) { @@ -105,7 +105,7 @@ export async function POST(request: NextRequest) { const formData = new FormData(); formData.append("title", title); formData.append("category", category); - formData.append("type", "task"); + formData.append("type", "kanban"); formData.append("user", JSON.stringify(user)); if (statuses) { diff --git a/package.json b/package.json index e11d6f1e..1b81c4d4 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,10 @@ "test:run": "vitest run", "test:coverage": "vitest run --coverage", "mock:data:lists": "tsx tests/mock-data/checklist-generator.ts", - "mock:data:notes": "tsx tests/mock-data/note-generator.ts" + "mock:data:notes": "tsx tests/mock-data/note-generator.ts", + "docker:test": "docker compose -f docker-compose.test.yml down && docker compose -f docker-compose.test.yml build && docker compose -f docker-compose.test.yml up -d", + "docker:test:logs": "docker compose -f docker-compose.test.yml logs -f", + "docker:test:restart": "docker compose -f docker-compose.test.yml down && docker compose -f docker-compose.test.yml up -d" }, "dependencies": { "@dnd-kit/core": "^6.3.1", diff --git a/tests/mock-data/constants.ts b/tests/mock-data/constants.ts index c433cdb7..58c62aad 100644 --- a/tests/mock-data/constants.ts +++ b/tests/mock-data/constants.ts @@ -1,4 +1,4 @@ -export const NUM_FILES = 300; +export const NUM_FILES = 10; export const TAG_POOL_SIZE = 80; export const SEED = 42; export const NUM_CATEGORIES = 7; From c8c95bbd5874e4f47da9a2e59c0f021e5f9684a9 Mon Sep 17 00:00:00 2001 From: fccview Date: Thu, 12 Mar 2026 14:43:23 +0000 Subject: [PATCH 02/26] update translations and add notifications --- .../Header/NotificationBell.tsx | 235 +++++++ .../FeatureComponents/Header/QuickNav.tsx | 3 + .../FeatureComponents/Kanban/CalendarView.tsx | 33 +- .../FeatureComponents/Kanban/Kanban.tsx | 8 +- .../FeatureComponents/Kanban/KanbanCard.tsx | 29 +- .../Kanban/KanbanCardDetail.tsx | 589 +++++------------- .../Kanban/KanbanCardDetailProperties.tsx | 220 +++++++ .../Kanban/KanbanCardDetailSubtasks.tsx | 127 ++++ app/_consts/files.ts | 4 + app/_hooks/kanban/useKanban.ts | 26 +- app/_hooks/kanban/useKanbanReminders.ts | 72 ++- app/_hooks/useNotifications.ts | 67 ++ app/_providers/WebSocketProvider.tsx | 23 +- app/_server/actions/checklist-item/crud.ts | 122 ++-- app/_server/actions/kanban/items.ts | 51 +- app/_server/actions/notifications/index.ts | 159 +++++ .../actions/sharing/share-operations.ts | 11 + app/_translations/de.json | 53 ++ app/_translations/en.json | 13 + app/_translations/es.json | 53 ++ app/_translations/fr.json | 53 ++ app/_translations/it.json | 53 ++ app/_translations/klingon.json | 53 ++ app/_translations/ko.json | 53 ++ app/_translations/nl.json | 53 ++ app/_translations/pirate.json | 53 ++ app/_translations/pl.json | 53 ++ app/_translations/ru.json | 53 ++ app/_translations/tr.json | 53 ++ app/_translations/zh.json | 53 ++ app/_types/index.ts | 2 + app/_types/notifications.ts | 18 + app/_types/websocket.ts | 2 +- app/_utils/kanban/index.tsx | 16 +- 34 files changed, 1878 insertions(+), 588 deletions(-) create mode 100644 app/_components/FeatureComponents/Header/NotificationBell.tsx create mode 100644 app/_components/FeatureComponents/Kanban/KanbanCardDetailProperties.tsx create mode 100644 app/_components/FeatureComponents/Kanban/KanbanCardDetailSubtasks.tsx create mode 100644 app/_hooks/useNotifications.ts create mode 100644 app/_server/actions/notifications/index.ts create mode 100644 app/_types/notifications.ts diff --git a/app/_components/FeatureComponents/Header/NotificationBell.tsx b/app/_components/FeatureComponents/Header/NotificationBell.tsx new file mode 100644 index 00000000..9a8a9fcc --- /dev/null +++ b/app/_components/FeatureComponents/Header/NotificationBell.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { useRef, useState, useEffect, useCallback } from "react"; +import { + Notification03Icon, + AlarmClockIcon, + UserCheck01Icon, + Share05Icon, + AlertCircleIcon, + MultiplicationSignIcon, + Delete02Icon, + ArrowRight01Icon, + Tick02Icon, +} from "hugeicons-react"; +import { useRouter } from "next/navigation"; +import { useNotifications } from "@/app/_hooks/useNotifications"; +import { AppNotification, NotificationType } from "@/app/_types"; +import { useTranslations } from "next-intl"; +import { cn } from "@/app/_utils/global-utils"; + +const _formatRelativeTime = (isoStr: string): string => { + const diff = Date.now() - new Date(isoStr).getTime(); + if (diff < 60000) return "Just now"; + if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`; + if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`; + return `${Math.floor(diff / 86400000)}d ago`; +}; + +const _getTypeIcon = (type: NotificationType) => { + switch (type) { + case "reminder": + return ( + + ); + case "assignment": + return ( + + ); + case "sharing": + return ; + default: + return ( + + ); + } +}; + +interface NotificationItemProps { + notification: AppNotification; + onRead: (id: string) => void; + onRemove: (id: string) => void; + onClose: () => void; +} + +const NotificationItem = ({ + notification, + onRead, + onRemove, + onClose, +}: NotificationItemProps) => { + const router = useRouter(); + + const handleClick = () => { + if (notification.link) { + router.push(notification.link); + onClose(); + } + }; + + return ( +
+
+ {_getTypeIcon(notification.type)} +
+

+ {notification.title} +

+

+ {notification.message} +

+
+ + {_formatRelativeTime(notification.createdAt)} + + {notification.link && ( + + + View + + )} +
+
+
+
+ {!notification.readAt && ( + + )} + +
+
+ ); +}; + +export const NotificationBell = () => { + const t = useTranslations(); + const dropdownRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const { + notifications, + unreadCount, + markAsRead, + markAllAsRead, + removeNotification, + clearAll, + } = useNotifications(); + + const handleClose = useCallback(() => setIsOpen(false), []); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + handleClose(); + } + }; + const handleEscape = (event: KeyboardEvent) => { + if (event.key === "Escape") handleClose(); + }; + + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("keydown", handleEscape); + } + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("keydown", handleEscape); + }; + }, [isOpen, handleClose]); + + return ( +
+ + + {isOpen && ( +
+
+ + {t("notifications.title")} + + {notifications.length > 0 && ( +
+ {unreadCount > 0 && ( + + )} + +
+ )} +
+ +
+ {notifications.length === 0 ? ( +
+ +

{t("notifications.empty")}

+
+ ) : ( + notifications.map((notification) => ( + + )) + )} +
+
+ )} +
+ ); +}; diff --git a/app/_components/FeatureComponents/Header/QuickNav.tsx b/app/_components/FeatureComponents/Header/QuickNav.tsx index ecf04152..1c99c597 100644 --- a/app/_components/FeatureComponents/Header/QuickNav.tsx +++ b/app/_components/FeatureComponents/Header/QuickNav.tsx @@ -17,6 +17,7 @@ import { cn, handleScroll } from "@/app/_utils/global-utils"; import { NavigationGlobalIcon } from "../Navigation/Parts/NavigationGlobalIcon"; import { NavigationSearchIcon } from "../Navigation/Parts/NavigationSearchIcon"; import { UserDropdown } from "../Navigation/Parts/UserDropdown"; +import { NotificationBell } from "./NotificationBell"; import { logout } from "@/app/_server/actions/auth"; import { useState, useEffect, useRef } from "react"; import { useTranslations } from "next-intl"; @@ -97,6 +98,7 @@ export const QuickNav = ({
+ {user && } {user && onOpenSettings ? ( + {user && }
diff --git a/app/_components/FeatureComponents/Kanban/CalendarView.tsx b/app/_components/FeatureComponents/Kanban/CalendarView.tsx index d868b665..cff468cc 100644 --- a/app/_components/FeatureComponents/Kanban/CalendarView.tsx +++ b/app/_components/FeatureComponents/Kanban/CalendarView.tsx @@ -10,7 +10,7 @@ import { Download04Icon, } from "hugeicons-react"; import { cn } from "@/app/_utils/global-utils"; -import { getPriorityColor } from "@/app/_utils/kanban/index"; +import { getPriorityDotColor } from "@/app/_utils/kanban/index"; import { useTranslations } from "next-intl"; interface CalendarViewProps { @@ -109,13 +109,13 @@ export const CalendarView = ({ checklist, onItemClick }: CalendarViewProps) => { key={dateStr} className={cn( "min-h-[100px] p-1 border-b border-r border-border transition-colors", - isToday && "bg-primary/5" + isToday && "bg-primary/5", )} >
{day.getDate()} @@ -125,24 +125,33 @@ export const CalendarView = ({ checklist, onItemClick }: CalendarViewProps) => {
{ - const item = checklist.items.find((i) => i.id === event.itemId); + const item = checklist.items.find( + (i) => i.id === event.itemId, + ); if (item && onItemClick) onItemClick(item); }} className={cn( - "text-[10px] px-1 py-0.5 rounded truncate cursor-pointer hover:opacity-80 transition-opacity", - event.completed - ? "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 line-through" - : event.priority - ? getPriorityColor(event.priority as any) - : "bg-primary/10 text-primary" + "text-[10px] px-1 py-0.5 rounded truncate cursor-pointer hover:opacity-80 transition-opacity flex items-center gap-1 bg-muted text-muted-foreground border-l-2", + event.completed && "line-through opacity-60", )} + style={{ + borderLeftColor: event.completed + ? "#22c55e" + : getPriorityDotColor( + event.priority as Parameters< + typeof getPriorityDotColor + >[0], + ), + }} > {event.title}
))} {dayEvents.length > 3 && (
- {t("kanban.moreEvents", { count: dayEvents.length - 3 })} + {t("kanban.moreEvents", { + count: dayEvents.length - 3, + })}
)}
@@ -164,7 +173,7 @@ export const CalendarView = ({ checklist, onItemClick }: CalendarViewProps) => {
onItemClick?.(item)} - className="text-xs px-2 py-1 rounded-full border border-border bg-muted/30 text-foreground cursor-pointer hover:bg-muted/50 transition-colors" + className="text-xs px-2 py-1 rounded-jotty border border-border bg-muted/30 text-foreground cursor-pointer hover:bg-muted/50 transition-colors" > {item.text}
diff --git a/app/_components/FeatureComponents/Kanban/Kanban.tsx b/app/_components/FeatureComponents/Kanban/Kanban.tsx index 7073ff8c..85515410 100644 --- a/app/_components/FeatureComponents/Kanban/Kanban.tsx +++ b/app/_components/FeatureComponents/Kanban/Kanban.tsx @@ -77,7 +77,12 @@ export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => { activeItem, } = useKanbanBoard({ checklist, onUpdate }); - useKanbanReminders(localChecklist); + useKanbanReminders({ + checklist: localChecklist, + checklistId: localChecklist.id, + category: localChecklist.category || "Uncategorized", + onUpdate: handleItemUpdate, + }); const statuses = useMemo(() => { const currentStatuses = localChecklist.statuses || DEFAULT_KANBAN_STATUSES; @@ -356,7 +361,6 @@ export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => { onUpdate={handleItemUpdate} checklistId={localChecklist.id} category={localChecklist.category || "Uncategorized"} - isShared={isShared} /> )} diff --git a/app/_components/FeatureComponents/Kanban/KanbanCard.tsx b/app/_components/FeatureComponents/Kanban/KanbanCard.tsx index 3afbb7c0..8d4f2362 100644 --- a/app/_components/FeatureComponents/Kanban/KanbanCard.tsx +++ b/app/_components/FeatureComponents/Kanban/KanbanCard.tsx @@ -14,7 +14,7 @@ import { formatTimerTime, getStatusColor, getStatusIcon, - getPriorityColor, + getPriorityDotColor, getPriorityLabel, } from "@/app/_utils/kanban/index"; import { TimeEntriesAccordion } from "./TimeEntriesAccordion"; @@ -26,6 +26,7 @@ import { formatReminderTime } from "@/app/_utils/kanban/reminder-utils"; import { CircleIcon, Notification03Icon, UserIcon } from "hugeicons-react"; import { usePreferredDateTime } from "@/app/_hooks/usePreferredDateTime"; import { useTranslations } from "next-intl"; +import { UserAvatar } from "../../GlobalComponents/User/UserAvatar"; interface KanbanCardProps { checklist: Checklist; @@ -116,7 +117,6 @@ const KanbanCardComponent = ({ setShowDetailModal(false)} onUpdate={onUpdate} @@ -162,31 +162,36 @@ const KanbanCardComponent = ({
{item.priority && item.priority !== "none" && ( - + + {getPriorityLabel(item.priority, t)} )} {item.score !== undefined && ( - + {t("kanban.scoreLabel", { score: item.score })} )} {item.assignee && ( - - + + {item.assignee} )} {item.reminder && !item.reminder.notified && ( - + {formatReminderTime(item.reminder.datetime)} diff --git a/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx b/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx index 592b09dd..032d2c36 100644 --- a/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx +++ b/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx @@ -3,8 +3,6 @@ import { useState, useEffect, useMemo } from "react"; import { Modal } from "@/app/_components/GlobalComponents/Modals/Modal"; import { Button } from "@/app/_components/GlobalComponents/Buttons/Button"; -import { Dropdown } from "@/app/_components/GlobalComponents/Dropdowns/Dropdown"; -import { UserAvatar } from "@/app/_components/GlobalComponents/User/UserAvatar"; import { Item, Checklist, KanbanPriority } from "@/app/_types"; import { createSubItem, @@ -12,22 +10,17 @@ import { deleteItem, bulkToggleItems, } from "@/app/_server/actions/checklist-item"; +import { createNotificationForUser } from "@/app/_server/actions/notifications"; import { getUsersWithAccess } from "@/app/_server/actions/sharing"; import { getUsers } from "@/app/_server/actions/users"; -import { - Add01Icon, - FloppyDiskIcon, - MultiplicationSignIcon, - UserIcon, -} from "hugeicons-react"; -import { NestedChecklistItem } from "@/app/_components/FeatureComponents/Checklists/Parts/Simple/NestedChecklistItem"; +import { FloppyDiskIcon, MultiplicationSignIcon } from "hugeicons-react"; import { convertMarkdownToHtml } from "@/app/_utils/markdown-utils"; import { usePermissions } from "@/app/_providers/PermissionsProvider"; import { usePreferredDateTime } from "@/app/_hooks/usePreferredDateTime"; import { useTranslations } from "next-intl"; -import { getPriorityColor, getPriorityLabel } from "@/app/_utils/kanban/index"; import { KanbanPriorityLevel } from "@/app/_types/enums"; -import { Router } from "next/router"; +import { KanbanCardDetailProperties } from "./KanbanCardDetailProperties"; +import { KanbanCardDetailSubtasks } from "./KanbanCardDetailSubtasks"; interface KanbanCardDetailProps { checklist: Checklist; @@ -37,14 +30,10 @@ interface KanbanCardDetailProps { onUpdate: (updatedChecklist: Checklist) => void; checklistId: string; category: string; - isShared: boolean; } -const _sanitizeDescription = (text: string): string => - text.replace(/\n/g, "\\n"); - -const _unsanitizeDescription = (text: string): string => - text.replace(/\\n/g, "\n"); +const _sanitizeDescription = (text: string): string => text.replace(/\n/g, "\\n"); +const _unsanitizeDescription = (text: string): string => text.replace(/\\n/g, "\n"); const _toLocalDateTimeValue = (isoStr: string): string => { if (!isoStr) return ""; @@ -62,6 +51,20 @@ const _toLocalDateValue = (isoStr: string): string => { return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; }; +const _findItemInChecklist = (checklist: Checklist, itemId: string): Item | null => { + const search = (items: Item[]): Item | null => { + for (const item of items) { + if (item.id === itemId) return item; + if (item.children) { + const found = search(item.children); + if (found) return found; + } + } + return null; + }; + return search(checklist.items); +}; + export const KanbanCardDetail = ({ checklist, item: initialItem, @@ -70,37 +73,21 @@ export const KanbanCardDetail = ({ onUpdate, checklistId, category, - isShared, }: KanbanCardDetailProps) => { const t = useTranslations(); const { permissions } = usePermissions(); const { formatDateTimeString } = usePreferredDateTime(); - console.log(initialItem); + const [item, setItem] = useState(initialItem); const [isEditing, setIsEditing] = useState(false); const [editText, setEditText] = useState(initialItem.text); - const [editDescription, setEditDescription] = useState( - _unsanitizeDescription(initialItem.description || ""), - ); - const [newSubtaskText, setNewSubtaskText] = useState(""); - const [scoreInput, setScoreInput] = useState( - initialItem.score?.toString() || "", - ); - const [reminderInput, setReminderInput] = useState( - initialItem.reminder?.datetime || "", - ); - const [targetDateInput, setTargetDateInput] = useState( - initialItem.targetDate || "", - ); - const [priorityInput, setPriorityInput] = useState( - initialItem.priority || KanbanPriorityLevel.NONE, - ); - const [assigneeInput, setAssigneeInput] = useState( - initialItem.assignee || "", - ); - const [availableUsers, setAvailableUsers] = useState< - { username: string; avatarUrl?: string }[] - >([]); + const [editDescription, setEditDescription] = useState(_unsanitizeDescription(initialItem.description || "")); + const [scoreInput, setScoreInput] = useState(initialItem.score?.toString() || ""); + const [reminderInput, setReminderInput] = useState(initialItem.reminder?.datetime || ""); + const [targetDateInput, setTargetDateInput] = useState(initialItem.targetDate || ""); + const [priorityInput, setPriorityInput] = useState(initialItem.priority || KanbanPriorityLevel.NONE); + const [assigneeInput, setAssigneeInput] = useState(initialItem.assignee || ""); + const [availableUsers, setAvailableUsers] = useState<{ username: string; avatarUrl?: string }[]>([]); useEffect(() => { setItem(initialItem); @@ -117,24 +104,13 @@ export const KanbanCardDetail = ({ if (!isOpen) return; const _loadUsers = async () => { const allUsers = await getUsers(); - const sharedWithUsers = await getUsersWithAccess( - checklistId, - checklist.uuid, - ); - const userMap = new Map< - string, - { username: string; avatarUrl?: string } - >(); + const sharedWithUsers = await getUsersWithAccess(checklistId, checklist.uuid); + const userMap = new Map(); allUsers.forEach((u: { username: string; avatarUrl?: string }) => { - userMap.set(u.username, { - username: u.username, - avatarUrl: u.avatarUrl, - }); + userMap.set(u.username, { username: u.username, avatarUrl: u.avatarUrl }); }); sharedWithUsers.forEach((username: string) => { - if (!userMap.has(username)) { - userMap.set(username, { username }); - } + if (!userMap.has(username)) userMap.set(username, { username }); }); if (checklist.owner && !userMap.has(checklist.owner)) { userMap.set(checklist.owner, { username: checklist.owner }); @@ -145,40 +121,18 @@ export const KanbanCardDetail = ({ }, [isOpen, checklistId, checklist.uuid, checklist.owner]); const descriptionHtml = useMemo(() => { - const noDescText = `

${t( - "checklists.noDescription", - )}

`; + const noDescText = `

${t("checklists.noDescription")}

`; if (!item.description) return noDescText; const unsanitized = _unsanitizeDescription(item.description); - const withLineBreaks = unsanitized.replace(/\n/g, " \n"); - return convertMarkdownToHtml(withLineBreaks) || noDescText; + return convertMarkdownToHtml(unsanitized.replace(/\n/g, " \n")) || noDescText; }, [item.description, t]); - const _findItemInChecklist = ( - checklist: Checklist, - itemId: string, - ): Item | null => { - const searchItems = (items: Item[]): Item | null => { - for (const item of items) { - if (item.id === itemId) return item; - if (item.children) { - const found = searchItems(item.children); - if (found) return found; - } - } - return null; - }; - return searchItems(checklist.items); - }; - const _saveField = async (fields: Record) => { const formData = new FormData(); formData.append("listId", checklistId); formData.append("itemId", item.id); formData.append("category", category); - Object.entries(fields).forEach(([key, value]) => { - formData.append(key, value); - }); + Object.entries(fields).forEach(([key, value]) => formData.append(key, value)); const result = await updateItem(checklist, formData); if (result.success && result.data) { onUpdate(result.data); @@ -190,18 +144,13 @@ export const KanbanCardDetail = ({ const handleSave = async () => { const sanitizedDescription = _sanitizeDescription(editDescription.trim()); const currentUnsanitized = _unsanitizeDescription(item.description || ""); - - if ( - editText.trim() !== item.text || - editDescription.trim() !== currentUnsanitized - ) { + if (editText.trim() !== item.text || editDescription.trim() !== currentUnsanitized) { const formData = new FormData(); formData.append("listId", checklistId); formData.append("itemId", item.id); formData.append("text", editText.trim()); formData.append("description", sanitizedDescription); formData.append("category", category); - const result = await updateItem(checklist, formData); if (result.success && result.data) { onUpdate(result.data); @@ -209,30 +158,24 @@ export const KanbanCardDetail = ({ if (updatedItem) { setItem(updatedItem); setEditText(updatedItem.text); - setEditDescription( - _unsanitizeDescription(updatedItem.description || ""), - ); + setEditDescription(_unsanitizeDescription(updatedItem.description || "")); } } } setIsEditing(false); }; - const handleAddSubtask = async () => { - if (!newSubtaskText.trim()) return; - + const handleAddSubtask = async (text: string) => { const formData = new FormData(); formData.append("listId", checklistId); formData.append("parentId", item.id); - formData.append("text", newSubtaskText.trim()); + formData.append("text", text); formData.append("category", category); - const result = await createSubItem(formData); if (result.success && result.data) { onUpdate(result.data); const updatedItem = _findItemInChecklist(result.data, item.id); if (updatedItem) setItem(updatedItem); - setNewSubtaskText(""); } }; @@ -242,7 +185,6 @@ export const KanbanCardDetail = ({ formData.append("parentId", parentId); formData.append("text", text); formData.append("category", category); - const result = await createSubItem(formData); if (result.success && result.data) { onUpdate(result.data); @@ -257,7 +199,6 @@ export const KanbanCardDetail = ({ formData.append("itemId", subtaskId); formData.append("completed", completed.toString()); formData.append("category", category); - const result = await updateItem(checklist, formData); if (result.success && result.data) { onUpdate(result.data); @@ -272,7 +213,6 @@ export const KanbanCardDetail = ({ formData.append("itemId", subtaskId); formData.append("text", text); formData.append("category", category); - const result = await updateItem(checklist, formData); if (result.success && result.data) { onUpdate(result.data); @@ -286,7 +226,6 @@ export const KanbanCardDetail = ({ formData.append("listId", checklistId); formData.append("itemId", subtaskId); formData.append("category", category); - const result = await deleteItem(formData); if (result.success && result.data) { onUpdate(result.data); @@ -297,28 +236,22 @@ export const KanbanCardDetail = ({ const handleToggleAll = async (completed: boolean) => { if (!item.children?.length) return; - const _findTargetItems = (items: Item[]): Item[] => { const targets: Item[] = []; items.forEach((subtask) => { const shouldToggle = completed ? !subtask.completed : subtask.completed; if (shouldToggle) targets.push(subtask); - if (subtask.children && subtask.children.length > 0) { - targets.push(..._findTargetItems(subtask.children)); - } + if (subtask.children?.length) targets.push(..._findTargetItems(subtask.children)); }); return targets; }; - const targetItems = _findTargetItems(item.children); - if (targetItems.length === 0) return; - + if (!targetItems.length) return; const formData = new FormData(); formData.append("listId", checklistId); formData.append("completed", String(completed)); formData.append("itemIds", JSON.stringify(targetItems.map((t) => t.id))); formData.append("category", category); - const result = await bulkToggleItems(formData); if (result.success && result.data) { onUpdate(result.data); @@ -338,107 +271,15 @@ export const KanbanCardDetail = ({ await _saveField({ score: score.toString() }); }; - const handleAssigneeChange = async (username: string) => { - setAssigneeInput(username); - await _saveField({ assignee: username }); - }; - const handleReminderSave = async () => { await _saveField({ - reminder: reminderInput - ? JSON.stringify({ datetime: new Date(reminderInput).toISOString() }) - : "", + reminder: reminderInput ? JSON.stringify({ datetime: new Date(reminderInput).toISOString() }) : "", }); }; const handleTargetDateChange = async (value: string) => { setTargetDateInput(value); - await _saveField({ - targetDate: value ? new Date(value).toISOString() : "", - }); - }; - - const priorities: KanbanPriority[] = [ - KanbanPriorityLevel.CRITICAL, - KanbanPriorityLevel.HIGH, - KanbanPriorityLevel.MEDIUM, - KanbanPriorityLevel.LOW, - KanbanPriorityLevel.NONE, - ]; - - const assigneeOptions = useMemo( - () => [ - { - id: "", - name: ( - - - {t("kanban.unassigned")} - - ), - }, - ...availableUsers.map((user) => ({ - id: user.username, - name: ( - - - {user.username} - - ), - })), - ], - [availableUsers, t], - ); - - const renderMetadata = () => { - const metadata = []; - - if (item.createdBy) { - metadata.push( - t("common.createdByOn", { - user: item.createdBy, - date: formatDateTimeString(item.createdAt!), - }), - ); - } - - if (item.lastModifiedBy) { - metadata.push( - t("common.lastModifiedByOn", { - user: item.lastModifiedBy, - date: formatDateTimeString(item.lastModifiedAt!), - }), - ); - } - - if (item.history?.length) { - metadata.push(t("common.statusChanges", { count: item.history.length })); - } - - return metadata.length ? ( -
-
-
- {t("auditLogs.metadata")} -
-
- {metadata.map((text, i) => ( -

- - {text} -

- ))} -
-
-
- ) : null; + await _saveField({ targetDate: value ? new Date(value).toISOString() : "" }); }; return ( @@ -448,264 +289,118 @@ export const KanbanCardDetail = ({ title={item.text || t("checklists.untitledTask")} className="lg:!max-w-[80vw] lg:!w-full lg:!h-[80vh] !max-h-[80vh] overflow-y-auto" > -
- {isEditing ? ( -
-
- - setEditText(e.target.value)} - className="w-full px-3 py-2 bg-background border border-input rounded-jotty focus:outline-none focus:ring-none focus:ring-ring focus:border-ring transition-all text-base" - placeholder={t("checklists.enterTaskTitle")} - onKeyDown={(e) => { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSave(); - } else if (e.key === "Escape") { - e.preventDefault(); - setEditText(item.text); - setEditDescription( - _unsanitizeDescription(item.description || ""), - ); - setIsEditing(false); - } - }} - /> -
-
- -