diff --git a/apps/web/src/utils/dueDateFilters.ts b/apps/web/src/utils/dueDateFilters.ts new file mode 100644 index 00000000..fc8f68ea --- /dev/null +++ b/apps/web/src/utils/dueDateFilters.ts @@ -0,0 +1,63 @@ +import { addDays, endOfDay, startOfDay } from "date-fns"; + +type DueDateFilterKey = + | "overdue" + | "today" + | "tomorrow" + | "next-week" + | "next-month" + | "no-due-date"; + +export interface DueDateFilter { + startDate?: string; + endDate?: string; + hasNoDueDate?: boolean; +} + +export const convertDueDateFiltersToRanges = ( + filters: DueDateFilterKey[], +): DueDateFilter[] => { + if (!filters.length) return []; + + const today = startOfDay(new Date()); + const tomorrow = addDays(today, 1); + const nextWeekEnd = addDays(today, 8); // 7 days ahead + const nextMonthEnd = addDays(today, 31); // 30 days ahead + + return filters.map((filter) => { + switch (filter) { + case "overdue": + return { + endDate: today.toISOString(), + }; + case "today": + return { + startDate: today.toISOString(), + endDate: endOfDay(today).toISOString(), + }; + case "tomorrow": + return { + startDate: tomorrow.toISOString(), + endDate: endOfDay(tomorrow).toISOString(), + }; + case "next-week": { + return { + startDate: today.toISOString(), + endDate: nextWeekEnd.toISOString(), + }; + } + case "next-month": { + return { + startDate: nextWeekEnd.toISOString(), + endDate: nextMonthEnd.toISOString(), + }; + } + case "no-due-date": + return { + hasNoDueDate: true, + }; + default: + return {}; + } + }); +}; diff --git a/apps/web/src/views/board/components/Filters.tsx b/apps/web/src/views/board/components/Filters.tsx index 3b281e8a..a76ee97d 100644 --- a/apps/web/src/views/board/components/Filters.tsx +++ b/apps/web/src/views/board/components/Filters.tsx @@ -2,6 +2,7 @@ import { useRouter } from "next/router"; import { t } from "@lingui/core/macro"; import { HiMiniXMark, + HiOutlineClock, HiOutlineSquare3Stack3D, HiOutlineTag, HiOutlineUserCircle, @@ -60,7 +61,13 @@ const Filters = ({ try { await router.push({ pathname: router.pathname, - query: { ...router.query, members: [], labels: [], lists: [] }, + query: { + ...router.query, + members: [], + labels: [], + lists: [], + dueDate: [], + }, }); } catch (error) { console.error(error); @@ -99,6 +106,39 @@ const Filters = ({ selected: !!router.query.lists?.includes(list.publicId), })); + const dueDateItems = [ + { + key: "overdue", + value: t`Overdue`, + selected: !!router.query.dueDate?.includes("overdue"), + }, + { + key: "today", + value: t`Due today`, + selected: !!router.query.dueDate?.includes("today"), + }, + { + key: "tomorrow", + value: t`Due tomorrow`, + selected: !!router.query.dueDate?.includes("tomorrow"), + }, + { + key: "next-week", + value: t`Due next week`, + selected: !!router.query.dueDate?.includes("next-week"), + }, + { + key: "next-month", + value: t`Due next month`, + selected: !!router.query.dueDate?.includes("next-month"), + }, + { + key: "no-due-date", + value: t`No dates`, + selected: !!router.query.dueDate?.includes("no-due-date"), + }, + ]; + const groups = [ ...(formattedMembers.length ? [ @@ -126,6 +166,12 @@ const Filters = ({ }, ] : []), + { + key: "dueDate", + label: t`Due date`, + icon: , + items: dueDateItems, + }, ]; const handleSelect = async ( @@ -156,6 +202,7 @@ const Filters = ({ ...formatToArray(router.query.members), ...formatToArray(router.query.labels), ...formatToArray(router.query.lists), + ...formatToArray(router.query.dueDate), ].length; return ( diff --git a/apps/web/src/views/board/index.tsx b/apps/web/src/views/board/index.tsx index 45e844fc..2b3e24da 100644 --- a/apps/web/src/views/board/index.tsx +++ b/apps/web/src/views/board/index.tsx @@ -29,6 +29,7 @@ import { useModal } from "~/providers/modal"; import { usePopup } from "~/providers/popup"; import { useWorkspace } from "~/providers/workspace"; import { api } from "~/utils/api"; +import { convertDueDateFiltersToRanges } from "~/utils/dueDateFilters"; import { formatToArray } from "~/utils/helpers"; import BoardDropdown from "./components/BoardDropdown"; import Card from "./components/Card"; @@ -87,17 +88,28 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) { }); }; + const semanticFilters = formatToArray(router.query.dueDate) as ( + | "overdue" + | "today" + | "tomorrow" + | "next-week" + | "next-month" + | "no-due-date" + )[]; + const queryParams: { boardPublicId: string; members: string[]; labels: string[]; lists: string[]; + dueDate: ReturnType; type: "regular" | "template"; } = { boardPublicId: boardId ?? "", members: formatToArray(router.query.members), labels: formatToArray(router.query.labels), lists: formatToArray(router.query.lists), + dueDate: convertDueDateFiltersToRanges(semanticFilters), type: isTemplate ? "template" : "regular", }; diff --git a/apps/web/src/views/public/board/index.tsx b/apps/web/src/views/public/board/index.tsx index d03668e9..48bc8f47 100644 --- a/apps/web/src/views/public/board/index.tsx +++ b/apps/web/src/views/public/board/index.tsx @@ -15,6 +15,7 @@ import ThemeToggle from "~/components/ThemeToggle"; import { useModal } from "~/providers/modal"; import { usePopup } from "~/providers/popup"; import { api } from "~/utils/api"; +import { convertDueDateFiltersToRanges } from "~/utils/dueDateFilters"; import { formatToArray } from "~/utils/helpers"; import Card from "~/views/board/components/Card"; import Filters from "~/views/board/components/Filters"; @@ -38,6 +39,15 @@ export default function PublicBoardView() { ? router.query.workspaceSlug[0] : router.query.workspaceSlug; + const dueDateFilters = formatToArray(router.query.dueDate) as ( + | "overdue" + | "today" + | "tomorrow" + | "next-week" + | "next-month" + | "no-due-date" + )[]; + const { data, isLoading } = api.board.bySlug.useQuery( { boardSlug: boardSlug ?? "", @@ -45,6 +55,7 @@ export default function PublicBoardView() { members: formatToArray(router.query.members), labels: formatToArray(router.query.labels), lists: formatToArray(router.query.lists), + dueDate: convertDueDateFiltersToRanges(dueDateFilters), }, { enabled: router.isReady && !!boardSlug, @@ -77,7 +88,7 @@ export default function PublicBoardView() { }; const pathWithoutQuery = router.asPath.split("?")[0]; - const splitPath = pathWithoutQuery.split("/"); + const splitPath = pathWithoutQuery?.split("/") ?? []; const cardPublicId = splitPath.length > 3 ? splitPath[3] : null; useEffect(() => { diff --git a/packages/api/src/routers/board.ts b/packages/api/src/routers/board.ts index 6e4da4d4..ffb6a0c3 100644 --- a/packages/api/src/routers/board.ts +++ b/packages/api/src/routers/board.ts @@ -13,6 +13,12 @@ import { generateSlug, generateUID } from "@kan/shared/utils"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; import { assertUserInWorkspace } from "../utils/auth"; +const dueDateFilterSchema = z.object({ + startDate: z.string().datetime().optional(), + endDate: z.string().datetime().optional(), + hasNoDueDate: z.boolean().optional(), +}); + export const boardRouter = createTRPCRouter({ all: protectedProcedure .meta({ @@ -79,6 +85,7 @@ export const boardRouter = createTRPCRouter({ members: z.array(z.string().min(12)).optional(), labels: z.array(z.string().min(12)).optional(), lists: z.array(z.string().min(12)).optional(), + dueDate: z.array(dueDateFilterSchema).optional(), type: z.enum(["regular", "template"]).optional(), }), ) @@ -112,6 +119,14 @@ export const boardRouter = createTRPCRouter({ members: input.members ?? [], labels: input.labels ?? [], lists: input.lists ?? [], + dueDate: + input.dueDate?.map((filter) => ({ + startDate: filter.startDate + ? new Date(filter.startDate) + : undefined, + endDate: filter.endDate ? new Date(filter.endDate) : undefined, + hasNoDueDate: filter.hasNoDueDate, + })) ?? [], type: input.type, }, ); @@ -145,6 +160,7 @@ export const boardRouter = createTRPCRouter({ members: z.array(z.string().min(12)).optional(), labels: z.array(z.string().min(12)).optional(), lists: z.array(z.string().min(12)).optional(), + dueDate: z.array(dueDateFilterSchema).optional(), }), ) .output(z.custom>>()) @@ -168,6 +184,14 @@ export const boardRouter = createTRPCRouter({ members: input.members ?? [], labels: input.labels ?? [], lists: input.lists ?? [], + dueDate: + input.dueDate?.map((filter) => ({ + startDate: filter.startDate + ? new Date(filter.startDate) + : undefined, + endDate: filter.endDate ? new Date(filter.endDate) : undefined, + hasNoDueDate: filter.hasNoDueDate, + })) ?? [], }, ); @@ -239,6 +263,7 @@ export const boardRouter = createTRPCRouter({ members: [], labels: [], lists: [], + dueDate: [], type: sourceBoardInfo.type, }, ); diff --git a/packages/db/src/repository/board.repo.ts b/packages/db/src/repository/board.repo.ts index 5a37635a..cf585d60 100644 --- a/packages/db/src/repository/board.repo.ts +++ b/packages/db/src/repository/board.repo.ts @@ -1,4 +1,15 @@ -import { and, asc, desc, eq, inArray, isNull, or } from "drizzle-orm"; +import { + and, + asc, + desc, + eq, + gte, + inArray, + isNotNull, + isNull, + lt, + or, +} from "drizzle-orm"; import type { dbClient } from "@kan/db/client"; import type { BoardVisibilityStatus } from "@kan/db/schema"; @@ -65,6 +76,39 @@ export const getIdByPublicId = async (db: dbClient, boardPublicId: string) => { return board; }; +interface DueDateFilter { + startDate?: Date; + endDate?: Date; + hasNoDueDate?: boolean; +} + +const buildDueDateWhere = (filters: DueDateFilter[]) => { + if (!filters.length) return undefined; + + const clauses = filters + .map((filter) => { + const conditions: ReturnType[] = []; + + if (filter.hasNoDueDate) { + conditions.push(isNull(cards.dueDate)); + } else { + conditions.push(isNotNull(cards.dueDate)); + + if (filter.startDate) + conditions.push(gte(cards.dueDate, filter.startDate)); + + if (filter.endDate) conditions.push(lt(cards.dueDate, filter.endDate)); + } + + return conditions.length > 0 ? and(...conditions) : undefined; + }) + .filter((clause): clause is NonNullable => !!clause); + + if (!clauses.length) return undefined; + + return or(...clauses); +}; + export const getByPublicId = async ( db: dbClient, boardPublicId: string, @@ -72,6 +116,7 @@ export const getByPublicId = async ( members: string[]; labels: string[]; lists: string[]; + dueDate: DueDateFilter[]; type: "regular" | "template" | undefined; }, ) => { @@ -237,6 +282,7 @@ export const getByPublicId = async ( where: and( cardIds.length > 0 ? inArray(cards.publicId, cardIds) : undefined, isNull(cards.deletedAt), + buildDueDateWhere(filters.dueDate), ), orderBy: [asc(cards.index)], }, @@ -292,6 +338,7 @@ export const getBySlug = async ( members: string[]; labels: string[]; lists: string[]; + dueDate: DueDateFilter[]; }, ) => { let cardIds: string[] = []; @@ -407,6 +454,7 @@ export const getBySlug = async ( where: and( cardIds.length > 0 ? inArray(cards.publicId, cardIds) : undefined, isNull(cards.deletedAt), + buildDueDateWhere(filters.dueDate), ), orderBy: [asc(cards.index)], },