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 <github@multica.ai>
This commit is contained in:
J
2026-07-13 16:03:41 +08:00
parent 1de4b2d688
commit 60416bbe01
11 changed files with 289 additions and 0 deletions

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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": "リンクされたイシューはありません",

View File

@@ -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": "연결된 이슈가 없습니다",

View File

@@ -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",

View File

@@ -89,6 +89,17 @@ vi.mock("../common/actor-avatar", () => ({
ActorAvatar: () => <span data-testid="actor-avatar" />,
}));
// 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: () => <button type="button">Start date</button>,
}));
vi.mock("../projects/components/project-due-date-picker", () => ({
ProjectDueDatePicker: () => <button type="button">Due date</button>,
}));
vi.mock("@multica/ui/components/ui/dialog", () => ({
Dialog: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
@@ -170,6 +181,13 @@ describe("CreateProjectModal", () => {
expect(screen.getByRole("tooltip", { name: longRepoUrl })).toBeInTheDocument();
});
it("renders the start-date and due-date pills", () => {
renderWithI18n(<CreateProjectModal onClose={vi.fn()} />);
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();

View File

@@ -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<string | undefined>(draft.leadId);
const [icon, setIcon] = useState<string | undefined>(draft.icon);
const [startDate, setStartDate] = useState<string>(draft.startDate ?? "");
const [dueDate, setDueDate] = useState<string>(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 }) {
</PopoverContent>
</Popover>
<ProjectStartDatePicker
startDate={startDate || null}
onUpdate={(u) => updateStartDate(u.start_date ?? "")}
triggerRender={<PillButton />}
/>
<ProjectDueDatePicker
dueDate={dueDate || null}
onUpdate={(u) => updateDueDate(u.due_date ?? "")}
triggerRender={<PillButton />}
/>
<Popover
open={repoPopoverOpen}
onOpenChange={(v) => {

View File

@@ -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(<ProjectStartDatePicker startDate={null} onUpdate={vi.fn()} />);
expect(screen.getByText("Start date")).toBeInTheDocument();
});
it("shows the formatted calendar day when a date is set", () => {
renderWithI18n(
<ProjectStartDatePicker startDate="2026-03-01" onUpdate={vi.fn()} />,
);
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(
<ProjectStartDatePicker startDate="2026-03-01" onUpdate={onUpdate} />,
);
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(<ProjectDueDatePicker dueDate={null} onUpdate={vi.fn()} />);
expect(screen.getByText("Due date")).toBeInTheDocument();
});
it("shows the formatted calendar day when a date is set", () => {
renderWithI18n(<ProjectDueDatePicker dueDate="2026-03-01" onUpdate={vi.fn()} />);
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(
<ProjectDueDatePicker dueDate="2026-03-01" onUpdate={onUpdate} />,
);
await user.click(screen.getByText("Mar 1")); // open popover
await user.click(screen.getByRole("button", { name: "Clear date" }));
expect(onUpdate).toHaveBeenCalledWith({ due_date: null });
});
});

View File

@@ -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 }) {
</PopoverContent>
</Popover>
</PropRow>
<PropRow label={t(($) => $.detail.prop_start_date)}>
<ProjectStartDatePicker startDate={project.start_date} onUpdate={handleUpdateField} />
</PropRow>
<PropRow label={t(($) => $.detail.prop_due_date)}>
<ProjectDueDatePicker dueDate={project.due_date} onUpdate={handleUpdateField} />
</PropRow>
</div>}
</div>

View File

@@ -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<UpdateProjectRequest>) => 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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
className={triggerRender ? undefined : "flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors"}
render={triggerRender}
>
<CalendarDays className="h-3.5 w-3.5 text-muted-foreground" />
{date ? (
<span className={isOverdue ? "text-destructive" : ""}>
{formatDateOnly(dueDate, { month: "short", day: "numeric" }, "en-US")}
</span>
) : (
<span className="text-muted-foreground">{t(($) => $.detail.prop_due_date)}</span>
)}
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align={align}>
<Calendar
mode="single"
selected={date}
onSelect={(d: Date | undefined) => {
onUpdate({ due_date: d ? toDateOnly(d) : null });
setOpen(false);
}}
/>
{date && (
<div className="border-t px-3 py-2">
<Button
variant="ghost"
size="xs"
onClick={() => {
onUpdate({ due_date: null });
setOpen(false);
}}
className="text-muted-foreground hover:text-foreground"
>
{t(($) => $.detail.clear_date)}
</Button>
</div>
)}
</PopoverContent>
</Popover>
);
}

View File

@@ -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<UpdateProjectRequest>) => 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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
className={triggerRender ? undefined : "flex items-center gap-1.5 cursor-pointer rounded px-1 -mx-1 hover:bg-accent/30 transition-colors"}
render={triggerRender}
>
<CalendarClock className="h-3.5 w-3.5 text-muted-foreground" />
{date ? (
<span>{formatDateOnly(startDate, { month: "short", day: "numeric" }, "en-US")}</span>
) : (
<span className="text-muted-foreground">{t(($) => $.detail.prop_start_date)}</span>
)}
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align={align}>
<Calendar
mode="single"
selected={date}
onSelect={(d: Date | undefined) => {
onUpdate({ start_date: d ? toDateOnly(d) : null });
setOpen(false);
}}
/>
{date && (
<div className="border-t px-3 py-2">
<Button
variant="ghost"
size="xs"
onClick={() => {
onUpdate({ start_date: null });
setOpen(false);
}}
className="text-muted-foreground hover:text-foreground"
>
{t(($) => $.detail.clear_date)}
</Button>
</div>
)}
</PopoverContent>
</Popover>
);
}