From 60416bbe015d72a3d82fe0edc25a53d9d29aea63 Mon Sep 17 00:00:00 2001 From: J Date: Mon, 13 Jul 2026 16:03:41 +0800 Subject: [PATCH] feat(projects): add start_date / due_date pickers to project create modal and sidebar #5313 landed the backend start_date/due_date fields + types but deliberately shipped no UI. Wire up the two editor surfaces users expect: - ProjectStartDatePicker / ProjectDueDatePicker mirror the issue pickers (same calendar-day contract, clear idiom, shared @multica/core/issues/date helpers) but are typed to UpdateProjectRequest and scoped to the "projects" i18n namespace. One component serves both surfaces via a custom trigger. - Create-project modal: two date pills; values flow into the create payload and the persisted draft (draft-store gains startDate/dueDate). - Project sidebar (project-detail): two PropRows after Lead, wired to the update mutation, with clear support (send null). - i18n: prop_start_date / prop_due_date / clear_date across en/zh-Hans/ja/ko, reusing the existing issue date wording. Tests: picker display + clear behavior (real popover), and the create modal renders both pills. typecheck + lint + i18n parity pass. Part of #5227 Co-authored-by: multica-agent --- packages/core/projects/draft-store.ts | 5 ++ packages/views/locales/en/projects.json | 3 + packages/views/locales/ja/projects.json | 3 + packages/views/locales/ko/projects.json | 3 + packages/views/locales/zh-Hans/projects.json | 3 + packages/views/modals/create-project.test.tsx | 18 ++++ packages/views/modals/create-project.tsx | 20 +++++ .../components/project-date-pickers.test.tsx | 54 ++++++++++++ .../projects/components/project-detail.tsx | 8 ++ .../components/project-due-date-picker.tsx | 87 +++++++++++++++++++ .../components/project-start-date-picker.tsx | 85 ++++++++++++++++++ 11 files changed, 289 insertions(+) create mode 100644 packages/views/projects/components/project-date-pickers.test.tsx create mode 100644 packages/views/projects/components/project-due-date-picker.tsx create mode 100644 packages/views/projects/components/project-start-date-picker.tsx diff --git a/packages/core/projects/draft-store.ts b/packages/core/projects/draft-store.ts index 8b10b9768..29d6bbebe 100644 --- a/packages/core/projects/draft-store.ts +++ b/packages/core/projects/draft-store.ts @@ -12,6 +12,9 @@ interface ProjectDraft { leadType?: "member" | "agent"; leadId?: string; icon?: string; + // Calendar days ("YYYY-MM-DD"); empty/undefined means unset. + startDate?: string; + dueDate?: string; } const EMPTY_DRAFT: ProjectDraft = { @@ -22,6 +25,8 @@ const EMPTY_DRAFT: ProjectDraft = { leadType: undefined, leadId: undefined, icon: undefined, + startDate: undefined, + dueDate: undefined, }; interface ProjectDraftStore { diff --git a/packages/views/locales/en/projects.json b/packages/views/locales/en/projects.json index e52c4dac6..595bfb3f0 100644 --- a/packages/views/locales/en/projects.json +++ b/packages/views/locales/en/projects.json @@ -68,6 +68,9 @@ "section_properties": "Properties", "section_progress": "Progress", "section_description": "Description", + "prop_start_date": "Start date", + "prop_due_date": "Due date", + "clear_date": "Clear date", "description_placeholder": "Add description...", "description_hint": "Shared with agents as context for every task in this project.", "empty_issues_title": "No issues linked", diff --git a/packages/views/locales/ja/projects.json b/packages/views/locales/ja/projects.json index f8ad32ea2..1ffdff855 100644 --- a/packages/views/locales/ja/projects.json +++ b/packages/views/locales/ja/projects.json @@ -67,6 +67,9 @@ "section_properties": "プロパティ", "section_progress": "進捗", "section_description": "説明", + "prop_start_date": "開始日", + "prop_due_date": "期限", + "clear_date": "日付をクリア", "description_placeholder": "説明を追加...", "description_hint": "このプロジェクトのすべてのタスクで、コンテキストとしてエージェントに共有されます。", "empty_issues_title": "リンクされたイシューはありません", diff --git a/packages/views/locales/ko/projects.json b/packages/views/locales/ko/projects.json index 9da75dbd2..ccbc96a7d 100644 --- a/packages/views/locales/ko/projects.json +++ b/packages/views/locales/ko/projects.json @@ -67,6 +67,9 @@ "section_properties": "속성", "section_progress": "진행률", "section_description": "설명", + "prop_start_date": "시작일", + "prop_due_date": "마감일", + "clear_date": "날짜 지우기", "description_placeholder": "설명 추가...", "description_hint": "이 프로젝트의 모든 작업에서 컨텍스트로 에이전트에게 공유됩니다.", "empty_issues_title": "연결된 이슈가 없습니다", diff --git a/packages/views/locales/zh-Hans/projects.json b/packages/views/locales/zh-Hans/projects.json index 7fed25b6d..f8c6e1108 100644 --- a/packages/views/locales/zh-Hans/projects.json +++ b/packages/views/locales/zh-Hans/projects.json @@ -67,6 +67,9 @@ "section_properties": "属性", "section_progress": "进度", "section_description": "描述", + "prop_start_date": "开始日期", + "prop_due_date": "截止日期", + "clear_date": "清除日期", "description_placeholder": "添加描述...", "description_hint": "会作为项目上下文提供给智能体,应用于该项目下的每个任务。", "empty_issues_title": "还没有关联的 issue", diff --git a/packages/views/modals/create-project.test.tsx b/packages/views/modals/create-project.test.tsx index 9f3004fac..8148b5066 100644 --- a/packages/views/modals/create-project.test.tsx +++ b/packages/views/modals/create-project.test.tsx @@ -89,6 +89,17 @@ vi.mock("../common/actor-avatar", () => ({ ActorAvatar: () => , })); +// Stub the date pickers so this test doesn't pull the real Calendar (and its +// buttonVariants import) into the modal's module graph; the pickers have their +// own test. The stubs render the placeholder label so the pills are assertable. +vi.mock("../projects/components/project-start-date-picker", () => ({ + ProjectStartDatePicker: () => , +})); + +vi.mock("../projects/components/project-due-date-picker", () => ({ + ProjectDueDatePicker: () => , +})); + vi.mock("@multica/ui/components/ui/dialog", () => ({ Dialog: ({ children }: { children: React.ReactNode }) =>
{children}
, DialogContent: ({ children }: { children: React.ReactNode }) =>
{children}
, @@ -170,6 +181,13 @@ describe("CreateProjectModal", () => { expect(screen.getByRole("tooltip", { name: longRepoUrl })).toBeInTheDocument(); }); + it("renders the start-date and due-date pills", () => { + renderWithI18n(); + + expect(screen.getByRole("button", { name: "Start date" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Due date" })).toBeInTheDocument(); + }); + it("filters workspace repositories by search text", async () => { const user = userEvent.setup(); diff --git a/packages/views/modals/create-project.tsx b/packages/views/modals/create-project.tsx index 07589b0b4..146810ee4 100644 --- a/packages/views/modals/create-project.tsx +++ b/packages/views/modals/create-project.tsx @@ -57,6 +57,8 @@ import { useProjectStatusLabels, useProjectPriorityLabels, } from "../projects/components/labels"; +import { ProjectStartDatePicker } from "../projects/components/project-start-date-picker"; +import { ProjectDueDatePicker } from "../projects/components/project-due-date-picker"; import { isDesktopShell, pickDirectory, @@ -134,6 +136,8 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) { const [leadType, setLeadType] = useState<"member" | "agent" | undefined>(draft.leadType); const [leadId, setLeadId] = useState(draft.leadId); const [icon, setIcon] = useState(draft.icon); + const [startDate, setStartDate] = useState(draft.startDate ?? ""); + const [dueDate, setDueDate] = useState(draft.dueDate ?? ""); const [iconPickerOpen, setIconPickerOpen] = useState(false); const [submitting, setSubmitting] = useState(false); const [isExpanded, setIsExpanded] = useState(false); @@ -212,6 +216,8 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) { setDraft({ leadType: type, leadId: id }); }; const updateIcon = (v: string | undefined) => { setIcon(v); setDraft({ icon: v }); }; + const updateStartDate = (v: string) => { setStartDate(v); setDraft({ startDate: v || undefined }); }; + const updateDueDate = (v: string) => { setDueDate(v); setDraft({ dueDate: v || undefined }); }; const [leadOpen, setLeadOpen] = useState(false); const [leadFilter, setLeadFilter] = useState(""); @@ -266,6 +272,8 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) { priority, lead_type: leadType, lead_id: leadId, + start_date: startDate || undefined, + due_date: dueDate || undefined, // Server attaches these in the same transaction as the project. resources, }); @@ -541,6 +549,18 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) { + updateStartDate(u.start_date ?? "")} + triggerRender={} + /> + + updateDueDate(u.due_date ?? "")} + triggerRender={} + /> + { diff --git a/packages/views/projects/components/project-date-pickers.test.tsx b/packages/views/projects/components/project-date-pickers.test.tsx new file mode 100644 index 000000000..52fdebdd9 --- /dev/null +++ b/packages/views/projects/components/project-date-pickers.test.tsx @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithI18n } from "../../test/i18n"; +import { ProjectStartDatePicker } from "./project-start-date-picker"; +import { ProjectDueDatePicker } from "./project-due-date-picker"; + +describe("ProjectStartDatePicker", () => { + it("shows the placeholder label when no date is set", () => { + renderWithI18n(); + expect(screen.getByText("Start date")).toBeInTheDocument(); + }); + + it("shows the formatted calendar day when a date is set", () => { + renderWithI18n( + , + ); + expect(screen.getByText("Mar 1")).toBeInTheDocument(); + }); + + it("emits start_date: null when the date is cleared", async () => { + const onUpdate = vi.fn(); + const user = userEvent.setup(); + renderWithI18n( + , + ); + await user.click(screen.getByText("Mar 1")); // open popover + await user.click(screen.getByRole("button", { name: "Clear date" })); + expect(onUpdate).toHaveBeenCalledWith({ start_date: null }); + }); +}); + +describe("ProjectDueDatePicker", () => { + it("shows the placeholder label when no date is set", () => { + renderWithI18n(); + expect(screen.getByText("Due date")).toBeInTheDocument(); + }); + + it("shows the formatted calendar day when a date is set", () => { + renderWithI18n(); + expect(screen.getByText("Mar 1")).toBeInTheDocument(); + }); + + it("emits due_date: null when the date is cleared", async () => { + const onUpdate = vi.fn(); + const user = userEvent.setup(); + renderWithI18n( + , + ); + await user.click(screen.getByText("Mar 1")); // open popover + await user.click(screen.getByRole("button", { name: "Clear date" })); + expect(onUpdate).toHaveBeenCalledWith({ due_date: null }); + }); +}); diff --git a/packages/views/projects/components/project-detail.tsx b/packages/views/projects/components/project-detail.tsx index 5c8e866f8..2b1914933 100644 --- a/packages/views/projects/components/project-detail.tsx +++ b/packages/views/projects/components/project-detail.tsx @@ -25,6 +25,8 @@ import { useNavigation } from "../../navigation"; import { TitleEditor, ContentEditor, type ContentEditorRef } from "../../editor"; import { PriorityIcon } from "../../issues/components/priority-icon"; import { ProjectResourcesSection } from "./project-resources-section"; +import { ProjectStartDatePicker } from "./project-start-date-picker"; +import { ProjectDueDatePicker } from "./project-due-date-picker"; import { IssueSurface } from "../../issues/surface/issue-surface"; import { Skeleton } from "@multica/ui/components/ui/skeleton"; import { Button } from "@multica/ui/components/ui/button"; @@ -398,6 +400,12 @@ export function ProjectDetail({ projectId }: { projectId: string }) { + $.detail.prop_start_date)}> + + + $.detail.prop_due_date)}> + + } diff --git a/packages/views/projects/components/project-due-date-picker.tsx b/packages/views/projects/components/project-due-date-picker.tsx new file mode 100644 index 000000000..5455ba8be --- /dev/null +++ b/packages/views/projects/components/project-due-date-picker.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useState } from "react"; +import { CalendarDays } from "lucide-react"; +import type { UpdateProjectRequest } from "@multica/core/types"; +import { + toDateOnly, + dateOnlyToLocalDate, + formatDateOnly, + isPastDateOnly, +} from "@multica/core/issues/date"; +import { Calendar } from "@multica/ui/components/ui/calendar"; +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@multica/ui/components/ui/popover"; +import { Button } from "@multica/ui/components/ui/button"; +import { useT } from "../../i18n"; + +/** + * Project due-date picker. Mirrors the issue DueDatePicker + * (packages/views/issues/components/pickers/due-date-picker.tsx), including the + * overdue `text-destructive` styling, but typed to UpdateProjectRequest and + * scoped to the "projects" i18n namespace. See ProjectStartDatePicker for the + * shared-component rationale. + */ +export function ProjectDueDatePicker({ + dueDate, + onUpdate, + triggerRender, + align = "start", +}: { + dueDate: string | null; + onUpdate: (updates: Partial) => void; + /** Custom trigger element (e.g. a pill button in the create modal). */ + triggerRender?: React.ReactElement; + align?: "start" | "center" | "end"; +}) { + const { t } = useT("projects"); + const [open, setOpen] = useState(false); + const date = dateOnlyToLocalDate(dueDate); + const isOverdue = isPastDateOnly(dueDate); + + return ( + + + + {date ? ( + + {formatDateOnly(dueDate, { month: "short", day: "numeric" }, "en-US")} + + ) : ( + {t(($) => $.detail.prop_due_date)} + )} + + + { + onUpdate({ due_date: d ? toDateOnly(d) : null }); + setOpen(false); + }} + /> + {date && ( +
+ +
+ )} +
+
+ ); +} diff --git a/packages/views/projects/components/project-start-date-picker.tsx b/packages/views/projects/components/project-start-date-picker.tsx new file mode 100644 index 000000000..21e6142db --- /dev/null +++ b/packages/views/projects/components/project-start-date-picker.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useState } from "react"; +import { CalendarClock } from "lucide-react"; +import type { UpdateProjectRequest } from "@multica/core/types"; +import { + toDateOnly, + dateOnlyToLocalDate, + formatDateOnly, +} from "@multica/core/issues/date"; +import { Calendar } from "@multica/ui/components/ui/calendar"; +import { + Popover, + PopoverTrigger, + PopoverContent, +} from "@multica/ui/components/ui/popover"; +import { Button } from "@multica/ui/components/ui/button"; +import { useT } from "../../i18n"; + +/** + * Project start-date picker. Mirrors the issue StartDatePicker + * (packages/views/issues/components/pickers/start-date-picker.tsx) — same + * calendar-day contract and clear idiom, reusing @multica/core/issues/date — + * but typed to UpdateProjectRequest and scoped to the "projects" i18n + * namespace. The same component serves both the create-project modal (map the + * emitted value into local draft state) and the project sidebar (pass the + * update mutation straight through). + */ +export function ProjectStartDatePicker({ + startDate, + onUpdate, + triggerRender, + align = "start", +}: { + startDate: string | null; + onUpdate: (updates: Partial) => void; + /** Custom trigger element (e.g. a pill button in the create modal). */ + triggerRender?: React.ReactElement; + align?: "start" | "center" | "end"; +}) { + const { t } = useT("projects"); + const [open, setOpen] = useState(false); + const date = dateOnlyToLocalDate(startDate); + + return ( + + + + {date ? ( + {formatDateOnly(startDate, { month: "short", day: "numeric" }, "en-US")} + ) : ( + {t(($) => $.detail.prop_start_date)} + )} + + + { + onUpdate({ start_date: d ? toDateOnly(d) : null }); + setOpen(false); + }} + /> + {date && ( +
+ +
+ )} +
+
+ ); +}