From b4dabd3706d2eb78e7f7eb3176db8bd120d56011 Mon Sep 17 00:00:00 2001 From: LordOfTheCorgis Date: Tue, 14 Apr 2026 00:11:20 -0500 Subject: [PATCH 1/7] Added Calendar + Sorting Cards by Due Date --- README.md | 2 + .../board/components/BoardCalendarView.tsx | 279 ++++++++++++++++++ apps/web/src/views/board/components/List.tsx | 17 ++ apps/web/src/views/board/index.tsx | 101 ++++++- 4 files changed, 395 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/views/board/components/BoardCalendarView.tsx diff --git a/README.md b/README.md index ea4527f9f..6c6c09aba 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ - πŸ” **Labels & Filters**: Organise and find cards quickly - πŸ’¬ **Comments**: Discuss and collaborate with your team - πŸ“ **Activity Log**: Track all card changes with detailed activity history +- πŸ“… **Board Calendar**: View cards with due dates on a month calendar +- ↕️ **List Sorting**: Sort cards in each column by due date to surface what’s coming next - 🎨 **Templates** : Save time with reusable custom board templates - ⚑️ **Integrations (coming soon)** : Connect your favourite tools diff --git a/apps/web/src/views/board/components/BoardCalendarView.tsx b/apps/web/src/views/board/components/BoardCalendarView.tsx new file mode 100644 index 000000000..146470e71 --- /dev/null +++ b/apps/web/src/views/board/components/BoardCalendarView.tsx @@ -0,0 +1,279 @@ +import Link from "next/link"; +import { t } from "@lingui/core/macro"; +import { + addMonths, + eachDayOfInterval, + endOfMonth, + endOfWeek, + format, + isSameDay, + isSameMonth, + isToday, + startOfMonth, + startOfWeek, + subMonths, +} from "date-fns"; +import { useMemo, useState } from "react"; +import { HiChevronLeft, HiChevronRight } from "react-icons/hi2"; +import { twMerge } from "tailwind-merge"; + +import Avatar from "~/components/Avatar"; +import LabelIcon from "~/components/LabelIcon"; +import { useLocalisation } from "~/hooks/useLocalisation"; +import { getAvatarUrl } from "~/utils/helpers"; + +interface CalendarCard { + publicId: string; + title: string; + dueDate: Date; + listName: string; + labels: { name: string; colourCode: string | null }[]; + members: { + publicId: string; + email: string; + user: { name: string | null; email: string; image: string | null } | null; + }[]; +} + +interface BoardCalendarViewProps { + boardPublicId: string; + isTemplate: boolean; + lists: { + publicId: string; + name: string; + cards: { + publicId: string; + title: string; + dueDate: Date | null; + labels: { name: string; colourCode: string | null }[]; + members: { + publicId: string; + email: string; + user: { + name: string | null; + email: string; + image: string | null; + } | null; + }[]; + }[]; + }[]; +} + +export function BoardCalendarView({ + boardPublicId, + isTemplate, + lists, +}: BoardCalendarViewProps) { + const { dateLocale } = useLocalisation(); + const [currentMonth, setCurrentMonth] = useState(() => + startOfMonth(new Date()), + ); + + const cardsWithDueDate = useMemo(() => { + return lists + .flatMap((list) => + list.cards + .filter( + ( + card, + ): card is typeof card & { + dueDate: Date; + } => card.dueDate !== null, + ) + .map((card) => ({ + publicId: card.publicId, + title: card.title, + dueDate: card.dueDate, + listName: list.name, + labels: card.labels, + members: card.members, + })), + ) + .sort((a, b) => a.dueDate.getTime() - b.dueDate.getTime()); + }, [lists]); + + const cardsByDay = useMemo(() => { + return cardsWithDueDate.reduce>( + (acc, card) => { + const key = format(card.dueDate, "yyyy-MM-dd"); + acc[key] = acc[key] ? [...acc[key], card] : [card]; + return acc; + }, + {}, + ); + }, [cardsWithDueDate]); + + const dayHeaders = useMemo(() => { + const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 }); + return eachDayOfInterval({ + start: weekStart, + end: new Date(weekStart.getTime() + 6 * 24 * 60 * 60 * 1000), + }).map((date) => format(date, "EEEEEE", { locale: dateLocale })); + }, [dateLocale]); + + const days = useMemo(() => { + const monthStart = startOfMonth(currentMonth); + const monthEnd = endOfMonth(currentMonth); + const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 }); + const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }); + + return eachDayOfInterval({ start: calendarStart, end: calendarEnd }); + }, [currentMonth]); + + const weeksInView = days.length / 7; + + const moveMonth = (direction: "next" | "prev") => { + setCurrentMonth((prev) => + direction === "next" ? addMonths(prev, 1) : subMonths(prev, 1), + ); + }; + + if (!cardsWithDueDate.length) { + return ( +
+

+ {t`No due dates yet`} +

+

+ {t`Add due dates to cards to see them in calendar view.`} +

+
+ ); + } + + return ( +
+
+ +
+ {format(currentMonth, "MMMM yyyy", { locale: dateLocale })} +
+ +
+ +
+ {dayHeaders.map((day, index) => ( +
{day}
+ ))} +
+ +
+ {days.map((day) => { + const key = format(day, "yyyy-MM-dd"); + const dueCards = cardsByDay[key] ?? []; + + return ( +
+
+ {format(day, "d")} +
+
+ {dueCards.slice(0, 2).map((card) => ( + +

+ {card.title} +

+
+
+ {card.labels.slice(0, 3).map((label, index) => ( + + + + ))} + {card.labels.length > 3 ? ( + + +{card.labels.length - 3} + + ) : null} +
+ +
+ {card.members.slice(0, 2).map((member) => ( + + ))} + {card.members.length > 2 ? ( + + +{card.members.length - 2} + + ) : null} +
+
+ + ))} + {dueCards.length > 2 ? ( +

+ +{dueCards.length - 2} {t`more`} +

+ ) : null} +
+
+ ); + })} +
+
+ ); +} diff --git a/apps/web/src/views/board/components/List.tsx b/apps/web/src/views/board/components/List.tsx index 258436b9d..7a29e1cb8 100644 --- a/apps/web/src/views/board/components/List.tsx +++ b/apps/web/src/views/board/components/List.tsx @@ -4,6 +4,7 @@ import { Draggable } from "react-beautiful-dnd"; import { useForm } from "react-hook-form"; import { HiEllipsisHorizontal, + HiOutlineClock, HiOutlinePlusSmall, HiOutlineSquaresPlus, HiOutlineTrash, @@ -21,6 +22,8 @@ interface ListProps { children: ReactNode; index: number; list: List; + sortMode: ListSortMode; + onSortModeChange: (sortMode: ListSortMode) => void; setSelectedPublicListId: (publicListId: PublicListId) => void; } @@ -36,11 +39,14 @@ interface FormValues { } type PublicListId = string; +type ListSortMode = "manual" | "due-date"; export default function List({ children, index, list, + sortMode, + onSortModeChange, setSelectedPublicListId, }: ListProps) { const { openModal } = useModal(); @@ -130,6 +136,17 @@ export default function List({ {(() => { const dropdownItems = [ + { + label: + sortMode === "due-date" + ? t`Show manual order` + : t`Sort by due date`, + action: () => + onSortModeChange( + sortMode === "due-date" ? "manual" : "due-date", + ), + icon: , + }, ...(canCreateCard ? [ { diff --git a/apps/web/src/views/board/index.tsx b/apps/web/src/views/board/index.tsx index 8d25696a7..41abdbee3 100644 --- a/apps/web/src/views/board/index.tsx +++ b/apps/web/src/views/board/index.tsx @@ -48,6 +48,7 @@ import { CardContextMoveListModal } from "./components/CardContextMoveListModal" import { DeleteBoardConfirmation } from "./components/DeleteBoardConfirmation"; import { DeleteListConfirmation } from "./components/DeleteListConfirmation"; import Filters from "./components/Filters"; +import { BoardCalendarView } from "./components/BoardCalendarView"; import List from "./components/List"; import { NewCardForm } from "./components/NewCardForm"; import { NewListForm } from "./components/NewListForm"; @@ -57,6 +58,39 @@ import { UpdateBoardSlugForm } from "./components/UpdateBoardSlugForm"; import VisibilityButton from "./components/VisibilityButton"; type PublicListId = string; +type BoardViewMode = "kanban" | "calendar"; +type ListSortMode = "manual" | "due-date"; + +interface SortableCard { + dueDate?: Date | null; +} + +function getSortedCards( + cards: T[], + sortMode: ListSortMode, +) { + if (sortMode === "manual") { + return cards; + } + + return [...cards] + .map((card, originalIndex) => ({ card, originalIndex })) + .sort((left, right) => { + const leftDueDate = left.card.dueDate + ? new Date(left.card.dueDate).getTime() + : Number.POSITIVE_INFINITY; + const rightDueDate = right.card.dueDate + ? new Date(right.card.dueDate).getTime() + : Number.POSITIVE_INFINITY; + + if (leftDueDate !== rightDueDate) { + return leftDueDate - rightDueDate; + } + + return left.originalIndex - right.originalIndex; + }) + .map(({ card }) => card); +} export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) { const params = useParams() as { boardId: string | string[] } | null; @@ -68,6 +102,10 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) { useModal(); const [selectedPublicListId, setSelectedPublicListId] = useState(""); + const [boardViewMode, setBoardViewMode] = useState("kanban"); + const [listSortModes, setListSortModes] = useState< + Record + >({}); const [isInitialLoading, setIsInitialLoading] = useState(true); const [contextMenu, setContextMenu] = useState<{ @@ -171,6 +209,20 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) { const isLoading = isInitialLoading || isQueryLoading; + const getListSortMode = (publicListId: PublicListId): ListSortMode => { + return listSortModes[publicListId] ?? "manual"; + }; + + const setListSortMode = ( + publicListId: PublicListId, + sortMode: ListSortMode, + ) => { + setListSortModes((prev) => ({ + ...prev, + [publicListId]: sortMode, + })); + }; + useScrollRestore( boardId, scrollRef, @@ -566,6 +618,30 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) { {t`Template`} )} +
+ + +
{!isTemplate && ( <> ) : boardData ? ( <> - {boardData.lists.length === 0 ? ( + {boardViewMode === "calendar" ? ( + + ) : boardData.lists.length === 0 ? (
@@ -689,8 +771,12 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) { {boardData.lists.map((list, index) => ( + setListSortMode(list.publicId, sortMode) + } setSelectedPublicListId={(publicListId) => setSelectedPublicListId(publicListId) } @@ -705,12 +791,19 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) { {...provided.droppableProps} className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-225px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-600" > - {list.cards.map((card, index) => ( + {getSortedCards( + list.cards, + getListSortMode(list.publicId), + ).map((card, index) => ( {(provided) => ( Date: Tue, 14 Apr 2026 00:16:35 -0500 Subject: [PATCH 2/7] Update README.md --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 6c6c09aba..b95ad6775 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ +## 🍴 Fork Information + +- Added Calendar View for Items w/ Due Dates +- Added Sorting for Cards for Items w/ Due Dates + ![github-background](https://github.com/user-attachments/assets/f728f52e-bf67-4357-9ba2-c24c437488e3)
From a0562d850c57ad2e0e9d668c1e38f26488c6957e Mon Sep 17 00:00:00 2001 From: LordOfTheCorgis Date: Tue, 14 Apr 2026 00:18:06 -0500 Subject: [PATCH 3/7] Update README.md --- README.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/README.md b/README.md index b95ad6775..6c6c09aba 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,3 @@ -## 🍴 Fork Information - -- Added Calendar View for Items w/ Due Dates -- Added Sorting for Cards for Items w/ Due Dates - ![github-background](https://github.com/user-attachments/assets/f728f52e-bf67-4357-9ba2-c24c437488e3)
From 4d26e0d1c848d642eb1ce6f590888c27e64c0fba Mon Sep 17 00:00:00 2001 From: LordOfTheCorgis Date: Tue, 14 Apr 2026 00:18:32 -0500 Subject: [PATCH 4/7] Update README.md --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 6c6c09aba..b95ad6775 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ +## 🍴 Fork Information + +- Added Calendar View for Items w/ Due Dates +- Added Sorting for Cards for Items w/ Due Dates + ![github-background](https://github.com/user-attachments/assets/f728f52e-bf67-4357-9ba2-c24c437488e3)
From 4bfdb442b5f64362101eaa71d39ab0cd928d1b5b Mon Sep 17 00:00:00 2001 From: LordOfTheCorgis Date: Tue, 14 Apr 2026 00:19:19 -0500 Subject: [PATCH 5/7] Update README.md --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index b95ad6775..aa4fb60c8 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,3 @@ -## 🍴 Fork Information - -- Added Calendar View for Items w/ Due Dates -- Added Sorting for Cards for Items w/ Due Dates - ![github-background](https://github.com/user-attachments/assets/f728f52e-bf67-4357-9ba2-c24c437488e3)
@@ -10,6 +5,11 @@

The open-source project management alternative to Trello.

+## 🍴 Fork Information + +- Added Calendar View for Items w/ Due Dates +- Added Sorting for Cards for Items w/ Due Dates +

Roadmap Β· From 724750f89bf8f78ee97eb978ad0cabd32710f90c Mon Sep 17 00:00:00 2001 From: LordOfTheCorgis Date: Tue, 14 Apr 2026 00:19:50 -0500 Subject: [PATCH 6/7] Update README.md --- README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index aa4fb60c8..588f8fb7a 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,6 @@

The open-source project management alternative to Trello.

-## 🍴 Fork Information - -- Added Calendar View for Items w/ Due Dates -- Added Sorting for Cards for Items w/ Due Dates -

Roadmap Β· @@ -24,6 +19,12 @@ License

+## Fork Information 🍴 + +- Added Calendar View for Items w/ Due Dates +- Added Sorting for Cards for Items w/ Due Dates + + ## Features πŸ’« - πŸ‘οΈ **Board Visibility**: Control who can view and edit your boards From 1c4d4ffab7c9db9256ba3a5f53625a5d6aa3410d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 5 May 2026 04:34:03 +0000 Subject: [PATCH 7/7] chore: update docker image references to LordOfTheCorgis repo Agent-Logs-Url: https://github.com/LordOfTheCorgis/kan/sessions/14f72198-d634-4732-b548-4bc8b537673f Co-authored-by: LordOfTheCorgis <128561696+LordOfTheCorgis@users.noreply.github.com> --- cloud/docker-compose.yml | 4 ++-- docker-compose.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cloud/docker-compose.yml b/cloud/docker-compose.yml index fba057b47..775df7416 100644 --- a/cloud/docker-compose.yml +++ b/cloud/docker-compose.yml @@ -1,6 +1,6 @@ services: migrator: - image: ghcr.io/kanbn/kan-migrate:latest + image: ghcr.io/lordofthecorgis/kan-migrate:latest container_name: ${MIGRATOR_CONTAINER_NAME:-kan-migrate} build: context: .. @@ -13,7 +13,7 @@ services: restart: "no" web: - image: ghcr.io/kanbn/kan:latest + image: ghcr.io/lordofthecorgis/kan:latest container_name: ${CONTAINER_NAME:-kan-web} ports: - "${WEB_PORT:-3000}:3000" diff --git a/docker-compose.yml b/docker-compose.yml index f731cebe6..c00b6f877 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,6 @@ services: migrate: - image: ghcr.io/kanbn/kan-migrate:latest + image: ghcr.io/lordofthecorgis/kan-migrate:latest container_name: ${CONTAINER_NAME:-kan-migrate} networks: - kan-network @@ -16,7 +16,7 @@ services: restart: "no" web: - image: ghcr.io/kanbn/kan:latest + image: ghcr.io/lordofthecorgis/kan:latest container_name: ${CONTAINER_NAME:-kan-web} ports: - "${WEB_PORT:-3000}:3000"