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)],
},