feat: add due date filters
This commit is contained in:
63
apps/web/src/utils/dueDateFilters.ts
Normal file
63
apps/web/src/utils/dueDateFilters.ts
Normal file
@@ -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 {};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ import { useRouter } from "next/router";
|
|||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import {
|
import {
|
||||||
HiMiniXMark,
|
HiMiniXMark,
|
||||||
|
HiOutlineClock,
|
||||||
HiOutlineSquare3Stack3D,
|
HiOutlineSquare3Stack3D,
|
||||||
HiOutlineTag,
|
HiOutlineTag,
|
||||||
HiOutlineUserCircle,
|
HiOutlineUserCircle,
|
||||||
@@ -60,7 +61,13 @@ const Filters = ({
|
|||||||
try {
|
try {
|
||||||
await router.push({
|
await router.push({
|
||||||
pathname: router.pathname,
|
pathname: router.pathname,
|
||||||
query: { ...router.query, members: [], labels: [], lists: [] },
|
query: {
|
||||||
|
...router.query,
|
||||||
|
members: [],
|
||||||
|
labels: [],
|
||||||
|
lists: [],
|
||||||
|
dueDate: [],
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -99,6 +106,39 @@ const Filters = ({
|
|||||||
selected: !!router.query.lists?.includes(list.publicId),
|
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 = [
|
const groups = [
|
||||||
...(formattedMembers.length
|
...(formattedMembers.length
|
||||||
? [
|
? [
|
||||||
@@ -126,6 +166,12 @@ const Filters = ({
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
{
|
||||||
|
key: "dueDate",
|
||||||
|
label: t`Due date`,
|
||||||
|
icon: <HiOutlineClock size={16} />,
|
||||||
|
items: dueDateItems,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleSelect = async (
|
const handleSelect = async (
|
||||||
@@ -156,6 +202,7 @@ const Filters = ({
|
|||||||
...formatToArray(router.query.members),
|
...formatToArray(router.query.members),
|
||||||
...formatToArray(router.query.labels),
|
...formatToArray(router.query.labels),
|
||||||
...formatToArray(router.query.lists),
|
...formatToArray(router.query.lists),
|
||||||
|
...formatToArray(router.query.dueDate),
|
||||||
].length;
|
].length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { useModal } from "~/providers/modal";
|
|||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
import { convertDueDateFiltersToRanges } from "~/utils/dueDateFilters";
|
||||||
import { formatToArray } from "~/utils/helpers";
|
import { formatToArray } from "~/utils/helpers";
|
||||||
import BoardDropdown from "./components/BoardDropdown";
|
import BoardDropdown from "./components/BoardDropdown";
|
||||||
import Card from "./components/Card";
|
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: {
|
const queryParams: {
|
||||||
boardPublicId: string;
|
boardPublicId: string;
|
||||||
members: string[];
|
members: string[];
|
||||||
labels: string[];
|
labels: string[];
|
||||||
lists: string[];
|
lists: string[];
|
||||||
|
dueDate: ReturnType<typeof convertDueDateFiltersToRanges>;
|
||||||
type: "regular" | "template";
|
type: "regular" | "template";
|
||||||
} = {
|
} = {
|
||||||
boardPublicId: boardId ?? "",
|
boardPublicId: boardId ?? "",
|
||||||
members: formatToArray(router.query.members),
|
members: formatToArray(router.query.members),
|
||||||
labels: formatToArray(router.query.labels),
|
labels: formatToArray(router.query.labels),
|
||||||
lists: formatToArray(router.query.lists),
|
lists: formatToArray(router.query.lists),
|
||||||
|
dueDate: convertDueDateFiltersToRanges(semanticFilters),
|
||||||
type: isTemplate ? "template" : "regular",
|
type: isTemplate ? "template" : "regular",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import ThemeToggle from "~/components/ThemeToggle";
|
|||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
import { usePopup } from "~/providers/popup";
|
import { usePopup } from "~/providers/popup";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
import { convertDueDateFiltersToRanges } from "~/utils/dueDateFilters";
|
||||||
import { formatToArray } from "~/utils/helpers";
|
import { formatToArray } from "~/utils/helpers";
|
||||||
import Card from "~/views/board/components/Card";
|
import Card from "~/views/board/components/Card";
|
||||||
import Filters from "~/views/board/components/Filters";
|
import Filters from "~/views/board/components/Filters";
|
||||||
@@ -38,6 +39,15 @@ export default function PublicBoardView() {
|
|||||||
? router.query.workspaceSlug[0]
|
? router.query.workspaceSlug[0]
|
||||||
: router.query.workspaceSlug;
|
: 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(
|
const { data, isLoading } = api.board.bySlug.useQuery(
|
||||||
{
|
{
|
||||||
boardSlug: boardSlug ?? "",
|
boardSlug: boardSlug ?? "",
|
||||||
@@ -45,6 +55,7 @@ export default function PublicBoardView() {
|
|||||||
members: formatToArray(router.query.members),
|
members: formatToArray(router.query.members),
|
||||||
labels: formatToArray(router.query.labels),
|
labels: formatToArray(router.query.labels),
|
||||||
lists: formatToArray(router.query.lists),
|
lists: formatToArray(router.query.lists),
|
||||||
|
dueDate: convertDueDateFiltersToRanges(dueDateFilters),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: router.isReady && !!boardSlug,
|
enabled: router.isReady && !!boardSlug,
|
||||||
@@ -77,7 +88,7 @@ export default function PublicBoardView() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const pathWithoutQuery = router.asPath.split("?")[0];
|
const pathWithoutQuery = router.asPath.split("?")[0];
|
||||||
const splitPath = pathWithoutQuery.split("/");
|
const splitPath = pathWithoutQuery?.split("/") ?? [];
|
||||||
const cardPublicId = splitPath.length > 3 ? splitPath[3] : null;
|
const cardPublicId = splitPath.length > 3 ? splitPath[3] : null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ import { generateSlug, generateUID } from "@kan/shared/utils";
|
|||||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||||
import { assertUserInWorkspace } from "../utils/auth";
|
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({
|
export const boardRouter = createTRPCRouter({
|
||||||
all: protectedProcedure
|
all: protectedProcedure
|
||||||
.meta({
|
.meta({
|
||||||
@@ -79,6 +85,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
members: z.array(z.string().min(12)).optional(),
|
members: z.array(z.string().min(12)).optional(),
|
||||||
labels: z.array(z.string().min(12)).optional(),
|
labels: z.array(z.string().min(12)).optional(),
|
||||||
lists: 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(),
|
type: z.enum(["regular", "template"]).optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -112,6 +119,14 @@ export const boardRouter = createTRPCRouter({
|
|||||||
members: input.members ?? [],
|
members: input.members ?? [],
|
||||||
labels: input.labels ?? [],
|
labels: input.labels ?? [],
|
||||||
lists: input.lists ?? [],
|
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,
|
type: input.type,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -145,6 +160,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
members: z.array(z.string().min(12)).optional(),
|
members: z.array(z.string().min(12)).optional(),
|
||||||
labels: z.array(z.string().min(12)).optional(),
|
labels: z.array(z.string().min(12)).optional(),
|
||||||
lists: z.array(z.string().min(12)).optional(),
|
lists: z.array(z.string().min(12)).optional(),
|
||||||
|
dueDate: z.array(dueDateFilterSchema).optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.output(z.custom<Awaited<ReturnType<typeof boardRepo.getBySlug>>>())
|
.output(z.custom<Awaited<ReturnType<typeof boardRepo.getBySlug>>>())
|
||||||
@@ -168,6 +184,14 @@ export const boardRouter = createTRPCRouter({
|
|||||||
members: input.members ?? [],
|
members: input.members ?? [],
|
||||||
labels: input.labels ?? [],
|
labels: input.labels ?? [],
|
||||||
lists: input.lists ?? [],
|
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: [],
|
members: [],
|
||||||
labels: [],
|
labels: [],
|
||||||
lists: [],
|
lists: [],
|
||||||
|
dueDate: [],
|
||||||
type: sourceBoardInfo.type,
|
type: sourceBoardInfo.type,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 { dbClient } from "@kan/db/client";
|
||||||
import type { BoardVisibilityStatus } from "@kan/db/schema";
|
import type { BoardVisibilityStatus } from "@kan/db/schema";
|
||||||
@@ -65,6 +76,39 @@ export const getIdByPublicId = async (db: dbClient, boardPublicId: string) => {
|
|||||||
return board;
|
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<typeof and>[] = [];
|
||||||
|
|
||||||
|
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<typeof clause> => !!clause);
|
||||||
|
|
||||||
|
if (!clauses.length) return undefined;
|
||||||
|
|
||||||
|
return or(...clauses);
|
||||||
|
};
|
||||||
|
|
||||||
export const getByPublicId = async (
|
export const getByPublicId = async (
|
||||||
db: dbClient,
|
db: dbClient,
|
||||||
boardPublicId: string,
|
boardPublicId: string,
|
||||||
@@ -72,6 +116,7 @@ export const getByPublicId = async (
|
|||||||
members: string[];
|
members: string[];
|
||||||
labels: string[];
|
labels: string[];
|
||||||
lists: string[];
|
lists: string[];
|
||||||
|
dueDate: DueDateFilter[];
|
||||||
type: "regular" | "template" | undefined;
|
type: "regular" | "template" | undefined;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
@@ -237,6 +282,7 @@ export const getByPublicId = async (
|
|||||||
where: and(
|
where: and(
|
||||||
cardIds.length > 0 ? inArray(cards.publicId, cardIds) : undefined,
|
cardIds.length > 0 ? inArray(cards.publicId, cardIds) : undefined,
|
||||||
isNull(cards.deletedAt),
|
isNull(cards.deletedAt),
|
||||||
|
buildDueDateWhere(filters.dueDate),
|
||||||
),
|
),
|
||||||
orderBy: [asc(cards.index)],
|
orderBy: [asc(cards.index)],
|
||||||
},
|
},
|
||||||
@@ -292,6 +338,7 @@ export const getBySlug = async (
|
|||||||
members: string[];
|
members: string[];
|
||||||
labels: string[];
|
labels: string[];
|
||||||
lists: string[];
|
lists: string[];
|
||||||
|
dueDate: DueDateFilter[];
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
let cardIds: string[] = [];
|
let cardIds: string[] = [];
|
||||||
@@ -407,6 +454,7 @@ export const getBySlug = async (
|
|||||||
where: and(
|
where: and(
|
||||||
cardIds.length > 0 ? inArray(cards.publicId, cardIds) : undefined,
|
cardIds.length > 0 ? inArray(cards.publicId, cardIds) : undefined,
|
||||||
isNull(cards.deletedAt),
|
isNull(cards.deletedAt),
|
||||||
|
buildDueDateWhere(filters.dueDate),
|
||||||
),
|
),
|
||||||
orderBy: [asc(cards.index)],
|
orderBy: [asc(cards.index)],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user