feat: card due dates (#271)

* feat: add card due dates to schema

* feat: update repo funcs

* feat: update card router to support due dates

* chore: update migration journal

* feat: add date selector

* feat: display date icon and label on cards

* feat: add due date filters

* feat: add due date to new card form

* feat: light mode tweaks

* feat: improve text eligibility on light mode

* feat: reduce selector font size

* feat: display date updates in card activity

* chore: gen translations
This commit is contained in:
Henry
2025-12-05 21:49:45 +00:00
committed by GitHub
parent 88f1aa0ec4
commit d208952d15
39 changed files with 5224 additions and 722 deletions

View File

@@ -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<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 (
db: dbClient,
boardPublicId: string,
@@ -72,6 +116,7 @@ export const getByPublicId = async (
members: string[];
labels: string[];
lists: string[];
dueDate: DueDateFilter[];
type: "regular" | "template" | undefined;
},
) => {
@@ -164,6 +209,7 @@ export const getByPublicId = async (
description: true,
listId: true,
index: true,
dueDate: true,
},
with: {
labels: {
@@ -236,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)],
},
@@ -291,6 +338,7 @@ export const getBySlug = async (
members: string[];
labels: string[];
lists: string[];
dueDate: DueDateFilter[];
},
) => {
let cardIds: string[] = [];
@@ -353,6 +401,7 @@ export const getBySlug = async (
description: true,
listId: true,
index: true,
dueDate: true,
},
with: {
labels: {
@@ -405,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)],
},

View File

@@ -23,6 +23,7 @@ export const create = async (
createdBy: string;
listId: number;
position: "start" | "end";
dueDate?: Date | null;
},
) => {
return db.transaction(async (tx) => {
@@ -71,6 +72,7 @@ export const create = async (
createdBy: cardInput.createdBy,
listId: cardInput.listId,
index: index,
dueDate: cardInput.dueDate ?? null,
})
.returning({ id: cards.id, listId: cards.listId });
@@ -163,6 +165,7 @@ export const update = async (
cardInput: {
title?: string;
description?: string;
dueDate?: Date | null;
},
args: {
cardPublicId: string;
@@ -173,6 +176,8 @@ export const update = async (
.set({
title: cardInput.title,
description: cardInput.description,
dueDate: cardInput.dueDate !== undefined ? cardInput.dueDate : undefined,
updatedAt: new Date(),
})
.where(and(eq(cards.publicId, args.cardPublicId), isNull(cards.deletedAt)))
.returning({
@@ -180,6 +185,7 @@ export const update = async (
publicId: cards.publicId,
title: cards.title,
description: cards.description,
dueDate: cards.dueDate,
});
return result;
@@ -214,6 +220,7 @@ export const getByPublicId = (db: dbClient, cardPublicId: string) => {
title: true,
description: true,
listId: true,
dueDate: true,
},
where: eq(cards.publicId, cardPublicId),
});
@@ -397,6 +404,7 @@ export const getWithListAndMembersByPublicId = async (
publicId: true,
title: true,
description: true,
dueDate: true,
},
with: {
labels: {
@@ -531,6 +539,8 @@ export const getWithListAndMembersByPublicId = async (
toTitle: true,
fromDescription: true,
toDescription: true,
fromDueDate: true,
toDueDate: true,
},
with: {
fromList: {
@@ -780,6 +790,7 @@ export const reorder = async (
publicId: true,
title: true,
description: true,
dueDate: true,
},
where: eq(cards.id, card.id),
});

View File

@@ -22,6 +22,8 @@ export const create = async (
commentId?: number;
fromComment?: string;
toComment?: string;
fromDueDate?: Date;
toDueDate?: Date;
sourceBoardId?: number;
},
) => {
@@ -45,6 +47,8 @@ export const create = async (
commentId: activityInput.commentId,
fromComment: activityInput.fromComment,
toComment: activityInput.toComment,
fromDueDate: activityInput.fromDueDate,
toDueDate: activityInput.toDueDate,
sourceBoardId: activityInput.sourceBoardId,
})
.returning({ id: cardActivities.id });
@@ -68,6 +72,8 @@ export const bulkCreate = async (
fromDescription?: string;
toDescription?: string;
createdBy: string;
fromDueDate?: Date;
toDueDate?: Date;
sourceBoardId?: number;
}[],
) => {

View File

@@ -44,6 +44,9 @@ export const activityTypes = [
"card.updated.checklist.item.deleted",
"card.updated.attachment.added",
"card.updated.attachment.removed",
"card.updated.dueDate.added",
"card.updated.dueDate.updated",
"card.updated.dueDate.removed",
"card.archived",
] as const;
@@ -70,6 +73,7 @@ export const cards = pgTable("card", {
.notNull()
.references(() => lists.id, { onDelete: "cascade" }),
importId: bigint("importId", { mode: "number" }).references(() => imports.id),
dueDate: timestamp("dueDate"),
}).enableRLS();
export const cardsRelations = relations(cards, ({ one, many }) => ({
@@ -137,6 +141,8 @@ export const cardActivities = pgTable("card_activity", {
),
fromComment: text("fromComment"),
toComment: text("toComment"),
fromDueDate: timestamp("fromDueDate"),
toDueDate: timestamp("toDueDate"),
sourceBoardId: bigint("sourceBoardId", { mode: "number" }).references(
() => boards.id,
{ onDelete: "set null" },