diff --git a/apps/web/src/components/CircularProgress.tsx b/apps/web/src/components/CircularProgress.tsx
new file mode 100644
index 00000000..b2690746
--- /dev/null
+++ b/apps/web/src/components/CircularProgress.tsx
@@ -0,0 +1,58 @@
+import { twMerge } from "tailwind-merge";
+
+interface CircularProgressProps {
+ progress: number; // 0-100
+ size?: "sm" | "md" | "lg";
+ className?: string;
+}
+
+const CircularProgress = ({
+ progress,
+ size = "md",
+ className,
+}: CircularProgressProps) => {
+ const radius = 40;
+ const circumference = 2 * Math.PI * radius;
+ const strokeDashoffset = circumference - (progress / 100) * circumference;
+
+ return (
+
+
+
+ );
+};
+
+export default CircularProgress;
diff --git a/apps/web/src/views/card/components/Dropdown.tsx b/apps/web/src/views/card/components/Dropdown.tsx
index 6aa19239..938c2f36 100644
--- a/apps/web/src/views/card/components/Dropdown.tsx
+++ b/apps/web/src/views/card/components/Dropdown.tsx
@@ -1,5 +1,9 @@
import { t } from "@lingui/core/macro";
-import { HiEllipsisHorizontal, HiOutlineTrash } from "react-icons/hi2";
+import {
+ HiEllipsisHorizontal,
+ HiOutlineCheckCircle,
+ HiOutlineTrash,
+} from "react-icons/hi2";
import Dropdown from "~/components/Dropdown";
import { useModal } from "~/providers/modal";
@@ -10,6 +14,13 @@ export default function BoardDropdown() {
return (
openModal("ADD_CHECKLIST"),
+ icon: (
+
+ ),
+ },
{
label: t`Delete card`,
action: () => openModal("DELETE_CARD"),
diff --git a/apps/web/src/views/card/components/NewChecklistForm.tsx b/apps/web/src/views/card/components/NewChecklistForm.tsx
new file mode 100644
index 00000000..e76ed4b5
--- /dev/null
+++ b/apps/web/src/views/card/components/NewChecklistForm.tsx
@@ -0,0 +1,126 @@
+import { t } from "@lingui/core/macro";
+import { useEffect } from "react";
+import { useForm } from "react-hook-form";
+import { HiXMark } from "react-icons/hi2";
+
+import { generateUID } from "@kan/shared/utils";
+
+import Button from "~/components/Button";
+import Input from "~/components/Input";
+import { useModal } from "~/providers/modal";
+import { usePopup } from "~/providers/popup";
+import { api } from "~/utils/api";
+
+interface NewChecklistFormInput {
+ name: string;
+ cardPublicId: string;
+}
+
+export function NewChecklistForm({ cardPublicId }: { cardPublicId: string }) {
+ const { closeModal } = useModal();
+ const { showPopup } = usePopup();
+
+ const utils = api.useUtils();
+
+ const { register, handleSubmit, reset, setValue, watch } =
+ useForm({
+ defaultValues: {
+ name: "Checklist",
+ cardPublicId,
+ },
+ });
+
+ const createChecklist = api.checklist.create.useMutation({
+ onMutate: async (args) => {
+ // await utils.board.byId.cancel();
+ // const currentState = utils.board.byId.getData(queryParams);
+ // utils.board.byId.setData(queryParams, (oldBoard) => {
+ // if (!oldBoard) return oldBoard;
+ // const newList = {
+ // publicId: generateUID(),
+ // name: args.name,
+ // boardId: 1,
+ // boardPublicId,
+ // cards: [],
+ // index: oldBoard.lists.length,
+ // };
+ // const updatedLists = [...oldBoard.lists, newList];
+ // return { ...oldBoard, lists: updatedLists };
+ // });
+ // return { previousState: currentState };
+ },
+ onError: (_error, _newList, context) => {
+ // utils.board.byId.setData(queryParams, context?.previousState);
+ showPopup({
+ header: t`Unable to create checklist`,
+ message: t`Please try again later, or contact customer support.`,
+ icon: "error",
+ });
+ },
+ // onSettled: async () => {
+ // await utils.board.byId.invalidate(queryParams);
+ // },
+ });
+
+ useEffect(() => {
+ const nameElement: HTMLElement | null =
+ document.querySelector("#checklist-name");
+ if (nameElement) nameElement.focus();
+ }, []);
+
+ const onSubmit = (data: NewChecklistFormInput) => {
+ closeModal();
+ reset({
+ name: "",
+ });
+
+ createChecklist.mutate({
+ name: data.name,
+ cardPublicId: data.cardPublicId,
+ });
+ };
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/views/card/index.tsx b/apps/web/src/views/card/index.tsx
index 06024832..ff31460d 100644
--- a/apps/web/src/views/card/index.tsx
+++ b/apps/web/src/views/card/index.tsx
@@ -5,6 +5,7 @@ import { useForm } from "react-hook-form";
import { IoChevronForwardSharp } from "react-icons/io5";
import Avatar from "~/components/Avatar";
+import CircularProgress from "~/components/CircularProgress";
import Editor from "~/components/Editor";
import FeedbackModal from "~/components/FeedbackModal";
import { LabelForm } from "~/components/LabelForm";
@@ -25,6 +26,7 @@ import Dropdown from "./components/Dropdown";
import LabelSelector from "./components/LabelSelector";
import ListSelector from "./components/ListSelector";
import MemberSelector from "./components/MemberSelector";
+import { NewChecklistForm } from "./components/NewChecklistForm";
import NewCommentForm from "./components/NewCommentForm";
interface FormValues {
@@ -251,6 +253,39 @@ export default function CardPage() {
+ {card.checklists.length > 0 && (
+
+
+ {card.checklists.map((checklist) => {
+ const completedItems = checklist.items.filter(
+ (item) => item.completed,
+ );
+ const progress =
+ checklist.items.length > 0
+ ? (completedItems.length / checklist.items.length) *
+ 100
+ : 2;
+
+ return (
+
+ {checklist.name}
+
+
+ {completedItems.length}/{checklist.items.length}
+
+
+ );
+ })}
+
+
+ )}
{t`Activity`}
@@ -303,6 +338,9 @@ export default function CardPage() {
/>
)}
{modalContentType === "NEW_WORKSPACE" && }
+ {modalContentType === "ADD_CHECKLIST" && (
+
+ )}
>
diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts
index 8005995d..f106930b 100644
--- a/packages/api/src/root.ts
+++ b/packages/api/src/root.ts
@@ -1,5 +1,6 @@
import { boardRouter } from "./routers/board";
import { cardRouter } from "./routers/card";
+import { checklistRouter } from "./routers/checklist";
import { feedbackRouter } from "./routers/feedback";
import { importRouter } from "./routers/import";
import { integrationRouter } from "./routers/integration";
@@ -13,6 +14,7 @@ import { createTRPCRouter } from "./trpc";
export const appRouter = createTRPCRouter({
board: boardRouter,
card: cardRouter,
+ checklist: checklistRouter,
feedback: feedbackRouter,
label: labelRouter,
list: listRouter,
diff --git a/packages/api/src/routers/checklists.ts b/packages/api/src/routers/checklist.ts
similarity index 94%
rename from packages/api/src/routers/checklists.ts
rename to packages/api/src/routers/checklist.ts
index a3aac701..cece89a4 100644
--- a/packages/api/src/routers/checklists.ts
+++ b/packages/api/src/routers/checklist.ts
@@ -8,8 +8,8 @@ import { createTRPCRouter, protectedProcedure } from "../trpc";
import { assertUserInWorkspace } from "../utils/auth";
const checklistSchema = z.object({
- publicId: z.string(),
- title: z.string(),
+ publicId: z.string().length(12),
+ name: z.string().min(1).max(255),
});
export const checklistRouter = createTRPCRouter({
@@ -26,8 +26,8 @@ export const checklistRouter = createTRPCRouter({
})
.input(
z.object({
- cardPublicId: z.string().min(12),
- title: z.string().min(1),
+ cardPublicId: z.string().length(12),
+ name: z.string().min(1).max(255),
}),
)
.output(checklistSchema)
@@ -54,7 +54,7 @@ export const checklistRouter = createTRPCRouter({
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
const newChecklist = await checklistRepo.create(ctx.db, {
- title: input.title,
+ name: input.name,
createdBy: userId,
cardId: card.id,
});
diff --git a/packages/db/migrations/20250715123447_AddCardChecklists.sql b/packages/db/migrations/20250806202106_glossy_luminals.sql
similarity index 99%
rename from packages/db/migrations/20250715123447_AddCardChecklists.sql
rename to packages/db/migrations/20250806202106_glossy_luminals.sql
index 25fcea2e..f4d0e38a 100644
--- a/packages/db/migrations/20250715123447_AddCardChecklists.sql
+++ b/packages/db/migrations/20250806202106_glossy_luminals.sql
@@ -17,7 +17,7 @@ ALTER TABLE "card_checklist_item" ENABLE ROW LEVEL SECURITY;--> statement-breakp
CREATE TABLE IF NOT EXISTS "card_checklist" (
"id" bigserial PRIMARY KEY NOT NULL,
"publicId" varchar(12) NOT NULL,
- "title" varchar(255) NOT NULL,
+ "name" varchar(255) NOT NULL,
"index" integer NOT NULL,
"cardId" bigint NOT NULL,
"createdBy" uuid,
diff --git a/packages/db/migrations/meta/20250715123447_snapshot.json b/packages/db/migrations/meta/20250806202106_snapshot.json
similarity index 99%
rename from packages/db/migrations/meta/20250715123447_snapshot.json
rename to packages/db/migrations/meta/20250806202106_snapshot.json
index 535c305b..f4257c76 100644
--- a/packages/db/migrations/meta/20250715123447_snapshot.json
+++ b/packages/db/migrations/meta/20250806202106_snapshot.json
@@ -1,5 +1,5 @@
{
- "id": "0b7ca39b-1114-4c0d-8e55-d932f40706ba",
+ "id": "957fcb16-856c-4756-ac3b-13f2a0814959",
"prevId": "91f9a2fa-31e2-4f3a-bdb9-852292fd7501",
"version": "7",
"dialect": "postgresql",
@@ -1344,8 +1344,8 @@
"primaryKey": false,
"notNull": true
},
- "title": {
- "name": "title",
+ "name": {
+ "name": "name",
"type": "varchar(255)",
"primaryKey": false,
"notNull": true
diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json
index 1b68777e..5dd0d430 100644
--- a/packages/db/migrations/meta/_journal.json
+++ b/packages/db/migrations/meta/_journal.json
@@ -54,9 +54,9 @@
{
"idx": 7,
"version": "7",
- "when": 1752582887553,
- "tag": "20250715123447_AddCardChecklists",
+ "when": 1754511666265,
+ "tag": "20250806202106_glossy_luminals",
"breakpoints": true
}
]
-}
+}
\ No newline at end of file
diff --git a/packages/db/src/repository/card.repo.ts b/packages/db/src/repository/card.repo.ts
index 32948552..34bbaa1b 100644
--- a/packages/db/src/repository/card.repo.ts
+++ b/packages/db/src/repository/card.repo.ts
@@ -6,6 +6,7 @@ import {
cards,
cardsToLabels,
cardToWorkspaceMembers,
+ checklists,
labels,
lists,
workspaceMembers,
@@ -300,6 +301,24 @@ export const getWithListAndMembersByPublicId = async (
},
},
},
+ checklists: {
+ columns: {
+ publicId: true,
+ name: true,
+ index: true,
+ },
+ where: isNull(checklists.deletedAt),
+ with: {
+ items: {
+ columns: {
+ publicId: true,
+ title: true,
+ completed: true,
+ index: true,
+ },
+ },
+ },
+ },
list: {
columns: {
publicId: true,
diff --git a/packages/db/src/repository/checklist.repo.ts b/packages/db/src/repository/checklist.repo.ts
new file mode 100644
index 00000000..1677e095
--- /dev/null
+++ b/packages/db/src/repository/checklist.repo.ts
@@ -0,0 +1,41 @@
+import { and, desc, eq, isNull } from "drizzle-orm";
+
+import type { dbClient } from "@kan/db/client";
+import { checklists } from "@kan/db/schema";
+import { generateUID } from "@kan/shared/utils";
+
+export const create = async (
+ db: dbClient,
+ checklistInput: {
+ cardId: number;
+ name: string;
+ createdBy: string;
+ },
+) => {
+ return db.transaction(async (tx) => {
+ const card = await tx.query.checklists.findFirst({
+ where: and(
+ eq(checklists.cardId, checklistInput.cardId),
+ isNull(checklists.deletedAt),
+ ),
+ orderBy: desc(checklists.index),
+ });
+
+ const [result] = await tx
+ .insert(checklists)
+ .values({
+ publicId: generateUID(),
+ name: checklistInput.name,
+ createdBy: checklistInput.createdBy,
+ cardId: checklistInput.cardId,
+ index: card ? card.index + 1 : 0,
+ })
+ .returning({
+ id: checklists.id,
+ publicId: checklists.publicId,
+ name: checklists.name,
+ });
+
+ return result;
+ });
+};
diff --git a/packages/db/src/schema/checklists.ts b/packages/db/src/schema/checklists.ts
index dbcd0d8e..b31d730c 100644
--- a/packages/db/src/schema/checklists.ts
+++ b/packages/db/src/schema/checklists.ts
@@ -16,7 +16,7 @@ import { users } from "./users";
export const checklists = pgTable("card_checklist", {
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
- title: varchar("title", { length: 255 }).notNull(),
+ name: varchar("name", { length: 255 }).notNull(),
index: integer("index").notNull(),
cardId: bigint("cardId", { mode: "number" })
.notNull()