Compare commits

...

2 Commits

Author SHA1 Message Date
Jiang Bohan
e65c72e81b fix(projects): scope Gantt to project surface + warn on hidden pages
- IssuesHeader / IssueDisplayControls now take `allowGantt` (default false);
  only Project Detail opts in. /issues, /my-issues and the actor panel no
  longer expose a Gantt option that silently fell through to List, and the
  toggle icon falls back to List when a stored `viewMode === "gantt"` lands
  on a surface that doesn't render it.
- Project Gantt now surfaces a banner with hidden-issue count plus a
  Load-all action that drains every remaining paginated page into the
  cache via the new `useLoadAllRemaining` helper. Pagination summary comes
  from `myIssueListPaginationOptions`, which shares the existing cache key
  with `myIssueListOptions` so totals stay in sync with Board/List.
- ScheduledRow normalizes a `start_date > due_date` anomaly to min/max and
  outlines the bar with a destructive ring + tooltip note, instead of
  silently dropping the row.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 15:50:24 +08:00
Jiang Bohan
51c96f2da1 feat(projects): add Project Gantt view (MUL-1881)
Adds Gantt as a third option in the Project page's view toggle (Board /
List / Gantt). Bars span start_date → due_date; issues with only one
date render as markers, issues with neither are collapsed into an
Unscheduled section. Toolbar exposes day/week/month zoom and a
show-completed toggle. The Gantt view shares the existing IssuesHeader
filters/sort.

Implementation is self-rendered SVG/HTML — no new dependencies. UTC
day-aligned date math keeps bars on the right columns regardless of
viewer timezone.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 15:23:34 +08:00
8 changed files with 933 additions and 12 deletions

View File

@@ -4,6 +4,7 @@ import { api } from "../api";
import {
issueKeys,
ISSUE_PAGE_SIZE,
PAGINATED_STATUSES,
type AssigneeGroupedIssuesFilter,
type MyIssuesFilter,
} from "./queries";
@@ -103,6 +104,75 @@ export function useLoadMoreByStatus(
return { loadMore, hasMore, isLoading, total };
}
/**
* Drain every remaining paginated page across all statuses into the cache.
* Used by surfaces that can't paginate per-column (e.g. the Project Gantt
* view) and need the full project issue set up-front. Each iteration appends
* one ISSUE_PAGE_SIZE page per status that still has unfetched rows; loops
* until the cache totals match the server.
*/
export function useLoadAllRemaining(
myIssues?: { scope: string; filter: MyIssuesFilter },
) {
const qc = useQueryClient();
const wsId = useWorkspaceId();
const [isLoading, setIsLoading] = useState(false);
const queryKey = myIssues
? issueKeys.myList(wsId, myIssues.scope, myIssues.filter)
: issueKeys.list(wsId);
const loadAll = useCallback(async () => {
if (isLoading) return;
setIsLoading(true);
try {
// Round-trip the cache rather than caching `loaded` locally so a
// concurrent WS-driven update or another loadMore can't make us
// re-fetch an already-loaded page.
for (;;) {
const cache = qc.getQueryData<ListIssuesCache>(queryKey);
if (!cache) return;
const pending = PAGINATED_STATUSES.filter((status) => {
const bucket = cache.byStatus[status];
if (!bucket) return false;
return bucket.issues.length < bucket.total;
});
if (pending.length === 0) return;
const results = await Promise.all(
pending.map((status) =>
api
.listIssues({
status,
limit: ISSUE_PAGE_SIZE,
offset: cache.byStatus[status]!.issues.length,
...myIssues?.filter,
})
.then((res) => ({ status, res })),
),
);
qc.setQueryData<ListIssuesCache>(queryKey, (old) => {
if (!old) return old;
let next = old;
for (const { status, res } of results) {
const prev = getBucket(next, status);
const existingIds = new Set(prev.issues.map((i) => i.id));
const appended = res.issues.filter((i) => !existingIds.has(i.id));
next = setBucket(next, status, {
issues: [...prev.issues, ...appended],
total: res.total,
});
}
return next;
});
}
} finally {
setIsLoading(false);
}
}, [isLoading, qc, queryKey, myIssues?.filter]);
return { loadAll, isLoading };
}
export function useLoadMoreByAssigneeGroup(
group: Pick<IssueAssigneeGroup, "id" | "assignee_type" | "assignee_id">,
queryKey: QueryKey,

View File

@@ -79,6 +79,34 @@ export function flattenIssueBuckets(data: ListIssuesCache) {
return out;
}
export interface IssueListPagination {
loaded: number;
total: number;
hasMore: boolean;
}
/**
* Aggregate the bucketed cache totals so non-paginated consumers (e.g. the
* Gantt view, which doesn't have a per-status load-more affordance) can tell
* whether the cache is missing pages and warn the user instead of silently
* rendering an incomplete schedule.
*/
export function summarizeIssueListPagination(
data: ListIssuesCache | undefined,
): IssueListPagination {
if (!data) return { loaded: 0, total: 0, hasMore: false };
let loaded = 0;
let total = 0;
for (const status of PAGINATED_STATUSES) {
const bucket = data.byStatus[status];
if (bucket) {
loaded += bucket.issues.length;
total += bucket.total;
}
}
return { loaded, total, hasMore: loaded < total };
}
async function fetchFirstPages(filter: MyIssuesFilter = {}): Promise<ListIssuesCache> {
const responses = await Promise.all(
PAGINATED_STATUSES.map((status) =>
@@ -142,6 +170,24 @@ export function myIssueListOptions(
});
}
/**
* Same cache entry as {@link myIssueListOptions} (shared queryKey + queryFn —
* TanStack Query dedupes), but `select` derives a pagination summary instead
* of the flat issue list. Use this alongside the list query when a consumer
* needs to know how many issues live behind unfetched pages.
*/
export function myIssueListPaginationOptions(
wsId: string,
scope: string,
filter: MyIssuesFilter,
) {
return queryOptions({
queryKey: issueKeys.myList(wsId, scope, filter),
queryFn: () => fetchFirstPages(filter),
select: summarizeIssueListPagination,
});
}
export function myIssueAssigneeGroupsOptions(
wsId: string,
scope: string,

View File

@@ -9,7 +9,8 @@ import { ALL_STATUSES } from "../config";
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
import { defaultStorage } from "../../platform/storage";
export type ViewMode = "board" | "list";
export type ViewMode = "board" | "list" | "gantt";
export type GanttZoom = "day" | "week" | "month";
export type IssueGrouping = "status" | "assignee";
export type SortField = "position" | "priority" | "start_date" | "due_date" | "created_at" | "title";
export type SortDirection = "asc" | "desc";
@@ -70,7 +71,11 @@ export interface IssueViewState {
sortDirection: SortDirection;
cardProperties: CardProperties;
listCollapsedStatuses: IssueStatus[];
ganttZoom: GanttZoom;
ganttShowCompleted: boolean;
setViewMode: (mode: ViewMode) => void;
setGanttZoom: (zoom: GanttZoom) => void;
toggleGanttShowCompleted: () => void;
setGrouping: (grouping: IssueGrouping) => void;
toggleStatusFilter: (status: IssueStatus) => void;
togglePriorityFilter: (priority: IssuePriority) => void;
@@ -113,8 +118,13 @@ export const viewStoreSlice = (set: StoreApi<IssueViewState>["setState"]): Issue
labels: true,
},
listCollapsedStatuses: [],
ganttZoom: "week",
ganttShowCompleted: false,
setViewMode: (mode) => set({ viewMode: mode }),
setGanttZoom: (zoom) => set({ ganttZoom: zoom }),
toggleGanttShowCompleted: () =>
set((state) => ({ ganttShowCompleted: !state.ganttShowCompleted })),
setGrouping: (grouping) => set({ grouping }),
toggleStatusFilter: (status) =>
set((state) => ({
@@ -232,6 +242,8 @@ export const viewStorePersistOptions = (name: string) => ({
sortDirection: state.sortDirection,
cardProperties: state.cardProperties,
listCollapsedStatuses: state.listCollapsedStatuses,
ganttZoom: state.ganttZoom,
ganttShowCompleted: state.ganttShowCompleted,
}),
// Default Zustand merge is shallow, so a persisted `cardProperties` snapshot
// saved before a new toggle was introduced wins entirely and the new key is

View File

@@ -0,0 +1,699 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ChevronRight, Info, Loader2 } from "lucide-react";
import { useWorkspaceId } from "@multica/core/hooks";
import { useWorkspacePaths } from "@multica/core/paths";
import { useViewStore, useViewStoreApi } from "@multica/core/issues/stores/view-store-context";
import type { GanttZoom } from "@multica/core/issues/stores/view-store";
import { projectListOptions } from "@multica/core/projects/queries";
import type { Issue, IssueStatus } from "@multica/core/types";
import { cn } from "@multica/ui/lib/utils";
import {
Tooltip,
TooltipTrigger,
TooltipContent,
} from "@multica/ui/components/ui/tooltip";
import { Button } from "@multica/ui/components/ui/button";
import { AppLink } from "../../navigation";
import { ActorAvatar } from "../../common/actor-avatar";
import { ProjectIcon } from "../../projects/components/project-icon";
import { StatusIcon } from "./status-icon";
import { PriorityIcon } from "./priority-icon";
import { IssueActionsContextMenu } from "../actions";
import { sortIssues } from "../utils/sort";
import { useT } from "../../i18n";
// ---------------------------------------------------------------------------
// Date utilities — everything is UTC-day-aligned so a `due_date` ISO string
// produced anywhere maps to exactly one column on the axis.
// ---------------------------------------------------------------------------
const MS_PER_DAY = 24 * 60 * 60 * 1000;
function startOfDayUTC(d: Date): Date {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
}
function addDays(d: Date, days: number): Date {
return new Date(d.getTime() + days * MS_PER_DAY);
}
function daysBetween(a: Date, b: Date): number {
return Math.round((b.getTime() - a.getTime()) / MS_PER_DAY);
}
function parseDay(iso: string | null): Date | null {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
return startOfDayUTC(d);
}
function isWeekendUTC(d: Date): boolean {
const wd = d.getUTCDay();
return wd === 0 || wd === 6;
}
function isMonthStartUTC(d: Date): boolean {
return d.getUTCDate() === 1;
}
function isWeekStartUTC(d: Date): boolean {
return d.getUTCDay() === 1; // Monday
}
// ---------------------------------------------------------------------------
// Geometry
// ---------------------------------------------------------------------------
const ROW_HEIGHT = 36;
const HEADER_HEIGHT = 56;
const LEFT_COL_WIDTH = 320;
const DAY_PX_BY_ZOOM: Record<GanttZoom, number> = {
day: 36,
week: 14,
month: 6,
};
interface Range {
start: Date;
end: Date;
}
function computeRange(issues: Issue[], today: Date, zoom: GanttZoom): Range {
const defaultPad: Record<GanttZoom, number> = {
day: 21,
week: 60,
month: 180,
};
let minTs = today.getTime() - defaultPad[zoom] * MS_PER_DAY;
let maxTs = today.getTime() + defaultPad[zoom] * MS_PER_DAY;
for (const i of issues) {
const s = parseDay(i.start_date);
const e = parseDay(i.due_date);
if (s && s.getTime() < minTs) minTs = s.getTime();
if (e && e.getTime() > maxTs) maxTs = e.getTime();
if (s && s.getTime() > maxTs) maxTs = s.getTime();
if (e && e.getTime() < minTs) minTs = e.getTime();
}
const pad = Math.max(2, Math.round(defaultPad[zoom] / 6));
return {
start: addDays(startOfDayUTC(new Date(minTs)), -pad),
end: addDays(startOfDayUTC(new Date(maxTs)), pad + 1),
};
}
// ---------------------------------------------------------------------------
// Top axis — sticky on vertical scroll. Renders month + day/week ticks.
// ---------------------------------------------------------------------------
function GanttAxis({
range,
dayPx,
zoom,
todayOffsetDays,
width,
}: {
range: Range;
dayPx: number;
zoom: GanttZoom;
todayOffsetDays: number;
width: number;
}) {
const locale = typeof navigator !== "undefined" ? navigator.language : "en";
const totalDays = daysBetween(range.start, range.end);
const monthBlocks = useMemo(() => {
const out: { label: string; left: number; width: number }[] = [];
let cursor = startOfDayUTC(range.start);
while (cursor.getTime() < range.end.getTime()) {
const monthEnd = new Date(
Date.UTC(cursor.getUTCFullYear(), cursor.getUTCMonth() + 1, 1),
);
const blockEnd = monthEnd.getTime() > range.end.getTime() ? range.end : monthEnd;
const startDays = daysBetween(range.start, cursor);
const widthDays = daysBetween(cursor, blockEnd);
out.push({
label: cursor.toLocaleDateString(locale, {
month: "short",
year: "numeric",
timeZone: "UTC",
}),
left: startDays * dayPx,
width: widthDays * dayPx,
});
cursor = monthEnd;
}
return out;
}, [range, dayPx, locale]);
return (
<div
className="relative shrink-0 border-b bg-background"
style={{ height: HEADER_HEIGHT, width }}
>
{/* Month row */}
<div className="relative h-7 border-b">
{monthBlocks.map((b, i) => (
<div
key={i}
className="absolute top-0 bottom-0 flex items-center px-2 text-xs font-medium text-foreground/80"
style={{ left: b.left, width: b.width }}
>
{b.width > 40 && <span className="truncate">{b.label}</span>}
</div>
))}
</div>
{/* Day / week ticks */}
<div className="relative h-7">
{Array.from({ length: totalDays }, (_, i) => {
const date = addDays(range.start, i);
const isMonth = isMonthStartUTC(date);
const isWeek = isWeekStartUTC(date);
const showLabel =
zoom === "day" ||
(zoom === "week" && isWeek) ||
(zoom === "month" && isMonth);
return (
<div
key={i}
className={cn(
"absolute top-0 bottom-0 flex items-center justify-center text-[10px] text-muted-foreground border-l",
isMonth
? "border-foreground/15"
: isWeek
? "border-foreground/10"
: "border-foreground/5",
)}
style={{ left: i * dayPx, width: dayPx }}
>
{showLabel && (
<div className="flex flex-col items-center leading-tight">
{zoom === "day" && (
<>
<span className="tabular-nums">{date.getUTCDate()}</span>
<span className="text-[9px] opacity-70">
{date.toLocaleDateString(locale, {
weekday: "short",
timeZone: "UTC",
})}
</span>
</>
)}
{zoom === "week" && (
<span className="tabular-nums">{date.getUTCDate()}</span>
)}
{zoom === "month" && (
<span className="tabular-nums whitespace-nowrap">
{date.toLocaleDateString(locale, {
month: "short",
day: "numeric",
timeZone: "UTC",
})}
</span>
)}
</div>
)}
</div>
);
})}
{todayOffsetDays >= 0 && todayOffsetDays <= totalDays && (
<div
className="absolute top-0 bottom-0 w-px bg-brand"
style={{ left: todayOffsetDays * dayPx }}
/>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Background layer — weekend shading, week/month gridlines, today line.
// Rendered once across the full timeline track height behind all bars.
// ---------------------------------------------------------------------------
function BackgroundLayer({
range,
dayPx,
height,
todayOffsetDays,
}: {
range: Range;
dayPx: number;
height: number;
todayOffsetDays: number;
}) {
const totalDays = daysBetween(range.start, range.end);
return (
<div
className="pointer-events-none absolute inset-0"
style={{ height, width: totalDays * dayPx }}
>
{Array.from({ length: totalDays }, (_, i) => {
const date = addDays(range.start, i);
const weekend = isWeekendUTC(date);
const isMonth = isMonthStartUTC(date);
const isWeek = isWeekStartUTC(date);
return (
<div
key={i}
className="absolute top-0 bottom-0"
style={{ left: i * dayPx, width: dayPx }}
>
{weekend && <div className="absolute inset-0 bg-muted/40" />}
{(isMonth || isWeek) && (
<div
className={cn(
"absolute top-0 bottom-0 left-0 w-px",
isMonth ? "bg-foreground/10" : "bg-foreground/5",
)}
/>
)}
</div>
);
})}
{todayOffsetDays >= 0 && todayOffsetDays <= totalDays && (
<div
className="absolute top-0 bottom-0 w-px bg-brand/70"
style={{ left: todayOffsetDays * dayPx }}
/>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Bar color by status (uses semantic Tailwind tokens, not hardcoded colors).
// ---------------------------------------------------------------------------
const STATUS_BAR_BG: Record<IssueStatus, string> = {
backlog: "bg-muted-foreground/60",
todo: "bg-muted-foreground/70",
in_progress: "bg-warning",
in_review: "bg-success",
done: "bg-info",
blocked: "bg-destructive",
cancelled: "bg-muted-foreground/40",
};
// ---------------------------------------------------------------------------
// One row — left label cell + right timeline track with absolute bar.
// ---------------------------------------------------------------------------
function ScheduledRow({
issue,
range,
dayPx,
totalDays,
}: {
issue: Issue;
range: Range;
dayPx: number;
totalDays: number;
}) {
const { t } = useT("issues");
const p = useWorkspacePaths();
const wsId = useWorkspaceId();
const { data: projects = [] } = useQuery({
...projectListOptions(wsId),
enabled: !!issue.project_id,
});
const project = issue.project_id ? projects.find((pr) => pr.id === issue.project_id) : undefined;
const start = parseDay(issue.start_date);
const due = parseDay(issue.due_date);
// start > due is a data anomaly (backend only validates RFC3339, not order).
// Normalize to min/max so the row still draws something, and flag it so the
// user notices instead of seeing a silently empty row.
const inverted =
start !== null && due !== null && start.getTime() > due.getTime();
const rangeStart = start && due ? (inverted ? due : start) : (start ?? due);
const rangeEnd = start && due ? (inverted ? start : due) : (start ?? due);
let bar: { left: number; width: number; isMarker: boolean } | null = null;
if (rangeStart && rangeEnd) {
const s = Math.max(daysBetween(range.start, rangeStart), 0);
const e = Math.min(daysBetween(range.start, rangeEnd) + 1, totalDays);
if (e > s) {
const isSingle = !start || !due;
if (isSingle) {
bar = { left: s * dayPx, width: Math.max(dayPx, 12), isMarker: true };
} else {
bar = { left: s * dayPx, width: (e - s) * dayPx, isMarker: false };
}
}
}
const locale = typeof navigator !== "undefined" ? navigator.language : "en";
const fmt = (d: Date) =>
d.toLocaleDateString(locale, {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
});
return (
<IssueActionsContextMenu issue={issue}>
<div
className="flex border-b border-foreground/5 hover:bg-accent/30 transition-colors"
style={{ height: ROW_HEIGHT }}
>
{/* Sticky label cell */}
<AppLink
href={p.issueDetail(issue.id)}
className="sticky left-0 z-[1] flex shrink-0 items-center gap-2 border-r bg-background px-3 text-sm min-w-0"
style={{ width: LEFT_COL_WIDTH }}
>
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
<PriorityIcon priority={issue.priority} />
<span className="w-14 shrink-0 text-xs text-muted-foreground tabular-nums truncate">
{issue.identifier}
</span>
<span className="truncate flex-1">{issue.title}</span>
{project && <ProjectIcon project={project} size="sm" />}
{issue.assignee_type && issue.assignee_id && (
<ActorAvatar
actorType={issue.assignee_type}
actorId={issue.assignee_id}
size={18}
enableHoverCard
/>
)}
</AppLink>
{/* Timeline track */}
<div
className="relative shrink-0"
style={{ width: totalDays * dayPx }}
>
{bar && (
<Tooltip>
<TooltipTrigger
render={
<AppLink
href={p.issueDetail(issue.id)}
className={cn(
"absolute top-1/2 -translate-y-1/2 transition-opacity hover:opacity-90",
bar.isMarker
? "h-3 w-3 rotate-45 rounded-[2px]"
: "h-5 rounded-md",
STATUS_BAR_BG[issue.status],
inverted && "ring-2 ring-destructive ring-offset-1 ring-offset-background",
)}
style={{ left: bar.left, width: bar.width }}
>
{!bar.isMarker && bar.width > 60 && (
<span className="block truncate px-2 py-[2px] text-[11px] leading-4 text-white/95">
{issue.title}
</span>
)}
</AppLink>
}
/>
<TooltipContent side="top">
<div className="flex flex-col gap-0.5 text-xs">
<span className="font-medium">{issue.title}</span>
<span className="text-muted-foreground">
{start ? fmt(start) : "—"} {due ? fmt(due) : "—"}
</span>
{inverted && (
<span className="text-destructive">
{t(($) => $.gantt.inverted_dates_warning)}
</span>
)}
</div>
</TooltipContent>
</Tooltip>
)}
</div>
</div>
</IssueActionsContextMenu>
);
}
function UnscheduledRow({ issue }: { issue: Issue }) {
const p = useWorkspacePaths();
return (
<IssueActionsContextMenu issue={issue}>
<AppLink
href={p.issueDetail(issue.id)}
className="sticky left-0 z-[1] flex items-center gap-2 bg-background px-3 py-1.5 text-sm hover:bg-accent/40 transition-colors border-b border-foreground/5"
style={{ width: LEFT_COL_WIDTH }}
>
<StatusIcon status={issue.status} className="h-3.5 w-3.5" />
<PriorityIcon priority={issue.priority} />
<span className="w-14 shrink-0 text-xs text-muted-foreground tabular-nums truncate">
{issue.identifier}
</span>
<span className="truncate flex-1">{issue.title}</span>
{issue.assignee_type && issue.assignee_id && (
<ActorAvatar
actorType={issue.assignee_type}
actorId={issue.assignee_id}
size={18}
enableHoverCard
/>
)}
</AppLink>
</IssueActionsContextMenu>
);
}
// ---------------------------------------------------------------------------
// GanttView — public component
// ---------------------------------------------------------------------------
/**
* Pagination meta surfaced by the host so the Gantt can warn the user when
* the in-memory issue list is incomplete. Unlike Board/List, Gantt has no
* column-level load-more, so without this it would silently render a
* partial schedule.
*/
export interface GanttPaginationProps {
loaded: number;
total: number;
hasMore: boolean;
isLoadingMore: boolean;
onLoadAll: () => void | Promise<void>;
}
export function GanttView({
issues,
pagination,
}: {
issues: Issue[];
pagination?: GanttPaginationProps;
}) {
const { t } = useT("issues");
const zoom = useViewStore((s) => s.ganttZoom);
const showCompleted = useViewStore((s) => s.ganttShowCompleted);
const sortBy = useViewStore((s) => s.sortBy);
const sortDirection = useViewStore((s) => s.sortDirection);
const act = useViewStoreApi().getState();
const today = useMemo(() => startOfDayUTC(new Date()), []);
const dayPx = DAY_PX_BY_ZOOM[zoom];
const visibleIssues = useMemo(() => {
const filtered = showCompleted
? issues
: issues.filter((i) => i.status !== "done" && i.status !== "cancelled");
// "position" makes no sense on a gantt — default to start_date asc when
// the user hasn't picked a more specific sort.
const sortField = sortBy === "position" ? "start_date" : sortBy;
return sortIssues(filtered, sortField, sortDirection);
}, [issues, showCompleted, sortBy, sortDirection]);
const scheduled = useMemo(
() => visibleIssues.filter((i) => i.start_date || i.due_date),
[visibleIssues],
);
const unscheduled = useMemo(
() => visibleIssues.filter((i) => !i.start_date && !i.due_date),
[visibleIssues],
);
const range = useMemo(
() => computeRange(scheduled, today, zoom),
[scheduled, today, zoom],
);
const totalDays = daysBetween(range.start, range.end);
const timelineWidth = totalDays * dayPx;
const todayOffsetDays = daysBetween(range.start, today);
const scrollRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const target = Math.max(0, LEFT_COL_WIDTH + todayOffsetDays * dayPx - 240);
el.scrollLeft = target;
}, [todayOffsetDays, dayPx]);
const [unscheduledOpen, setUnscheduledOpen] = useState(false);
if (visibleIssues.length === 0) {
return (
<div className="flex-1 min-h-0 flex items-center justify-center text-sm text-muted-foreground">
{t(($) => $.gantt.empty)}
</div>
);
}
const hiddenCount =
pagination && pagination.hasMore
? Math.max(0, pagination.total - pagination.loaded)
: 0;
return (
<div className="flex flex-col flex-1 min-h-0">
{/* Toolbar */}
<div className="flex h-9 shrink-0 items-center gap-2 border-b px-3">
<div className="inline-flex items-center rounded-md border border-foreground/10 p-0.5">
{([
{ value: "day", label: t(($) => $.gantt.zoom_day) },
{ value: "week", label: t(($) => $.gantt.zoom_week) },
{ value: "month", label: t(($) => $.gantt.zoom_month) },
] as const).map((opt) => (
<Button
key={opt.value}
size="sm"
variant={zoom === opt.value ? "secondary" : "ghost"}
className={cn(
"h-6 px-2 text-xs",
zoom !== opt.value && "text-muted-foreground",
)}
onClick={() => act.setGanttZoom(opt.value)}
>
{opt.label}
</Button>
))}
</div>
<div className="flex-1" />
<Button
size="sm"
variant={showCompleted ? "secondary" : "outline"}
className={cn(
"h-7 text-xs",
!showCompleted && "text-muted-foreground",
)}
onClick={act.toggleGanttShowCompleted}
>
{t(($) => $.gantt.show_completed)}
</Button>
</div>
{hiddenCount > 0 && pagination && (
<div className="flex shrink-0 items-center gap-2 border-b bg-warning/10 px-3 py-1.5 text-xs">
<Info className="size-3.5 shrink-0 text-warning" />
<span className="text-muted-foreground">
{t(($) => $.gantt.pagination_warning, { hidden: hiddenCount })}
</span>
<div className="flex-1" />
<Button
size="sm"
variant="outline"
className="h-6 text-xs"
disabled={pagination.isLoadingMore}
onClick={() => {
void pagination.onLoadAll();
}}
>
{pagination.isLoadingMore && (
<Loader2 className="size-3 animate-spin" />
)}
{t(($) => $.gantt.load_all)}
</Button>
</div>
)}
{/* Body — single scroll container drives both vertical + horizontal */}
<div ref={scrollRef} className="flex-1 min-h-0 overflow-auto">
<div style={{ minWidth: LEFT_COL_WIDTH + timelineWidth }}>
{/* Sticky header row */}
<div className="sticky top-0 z-20 flex">
<div
className="sticky left-0 z-30 shrink-0 border-b border-r bg-background"
style={{ width: LEFT_COL_WIDTH, height: HEADER_HEIGHT }}
>
<div className="flex h-full items-end px-3 pb-1.5 text-[11px] font-medium text-muted-foreground">
{t(($) => $.gantt.header_issue)}
</div>
</div>
<GanttAxis
range={range}
dayPx={dayPx}
zoom={zoom}
todayOffsetDays={todayOffsetDays}
width={timelineWidth}
/>
</div>
{/* Scheduled rows + background overlay */}
{scheduled.length > 0 ? (
<div className="relative">
{/* Background gridlines + today line spanning all rows. Positioned
starting after the left label column. */}
<div
className="pointer-events-none absolute top-0"
style={{ left: LEFT_COL_WIDTH, width: timelineWidth }}
>
<BackgroundLayer
range={range}
dayPx={dayPx}
height={scheduled.length * ROW_HEIGHT}
todayOffsetDays={todayOffsetDays}
/>
</div>
{scheduled.map((issue) => (
<ScheduledRow
key={issue.id}
issue={issue}
range={range}
dayPx={dayPx}
totalDays={totalDays}
/>
))}
</div>
) : (
<div
className="flex items-center py-10 text-sm text-muted-foreground border-b"
style={{ paddingLeft: LEFT_COL_WIDTH + 12 }}
>
{t(($) => $.gantt.no_scheduled)}
</div>
)}
{/* Unscheduled section */}
{unscheduled.length > 0 && (
<div className="border-t bg-muted/15">
<button
className="sticky left-0 z-[1] flex items-center gap-2 px-3 py-2 text-left text-xs font-medium hover:bg-accent/40 transition-colors w-full bg-muted/15"
onClick={() => setUnscheduledOpen((v) => !v)}
style={{ width: LEFT_COL_WIDTH }}
>
<ChevronRight
className={cn(
"size-3.5 text-muted-foreground transition-transform",
unscheduledOpen && "rotate-90",
)}
/>
<span className="text-muted-foreground">
{t(($) => $.gantt.unscheduled_section, { count: unscheduled.length })}
</span>
</button>
{unscheduledOpen &&
unscheduled.map((issue) => (
<UnscheduledRow key={issue.id} issue={issue} />
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
import {
ArrowDown,
ArrowUp,
ChartGantt,
Check,
ChevronDown,
CircleDot,
@@ -490,7 +491,13 @@ function LabelSubContent({
// IssuesHeader
// ---------------------------------------------------------------------------
export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
export function IssuesHeader({
scopedIssues,
allowGantt = false,
}: {
scopedIssues: Issue[];
allowGantt?: boolean;
}) {
const { t } = useT("issues");
const scope = useIssuesScopeStore((s) => s.scope);
const setScope = useIssuesScopeStore((s) => s.setScope);
@@ -532,7 +539,7 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
))}
</div>
<IssueDisplayControls scopedIssues={scopedIssues} />
<IssueDisplayControls scopedIssues={scopedIssues} allowGantt={allowGantt} />
</div>
);
}
@@ -540,9 +547,14 @@ export function IssuesHeader({ scopedIssues }: { scopedIssues: Issue[] }) {
export function IssueDisplayControls({
scopedIssues,
hideViewToggle = false,
allowGantt = false,
}: {
scopedIssues: Issue[];
hideViewToggle?: boolean;
// Only Project Detail renders <GanttView>; other surfaces (global /issues,
// /my-issues, actor panel) ignore viewMode === "gantt" and would silently
// fall back to List if the option were exposed there. Keep Gantt opt-in.
allowGantt?: boolean;
}) {
const { t } = useT("issues");
const viewMode = useViewStore((s) => s.viewMode);
@@ -913,7 +925,9 @@ export function IssueDisplayControls({
</PopoverContent>
</Popover>
{/* View toggle */}
{/* View toggle. If a store has `viewMode === "gantt"` persisted but
this surface doesn't render Gantt, fall back to "list" so the
trigger icon matches what's actually on screen. */}
{!hideViewToggle && (
<DropdownMenu>
<Tooltip>
@@ -924,6 +938,8 @@ export function IssueDisplayControls({
<Button variant="outline" size="icon-sm" className="text-muted-foreground">
{viewMode === "board" ? (
<Columns3 className="size-4" />
) : viewMode === "gantt" && allowGantt ? (
<ChartGantt className="size-4" />
) : (
<List className="size-4" />
)}
@@ -933,7 +949,11 @@ export function IssueDisplayControls({
}
/>
<TooltipContent side="bottom">
{viewMode === "board" ? t(($) => $.view.tooltip_board) : t(($) => $.view.tooltip_list)}
{viewMode === "board"
? t(($) => $.view.tooltip_board)
: viewMode === "gantt" && allowGantt
? t(($) => $.view.tooltip_gantt)
: t(($) => $.view.tooltip_list)}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-auto">
@@ -947,6 +967,12 @@ export function IssueDisplayControls({
<List />
{t(($) => $.view.list)}
</DropdownMenuItem>
{allowGantt && (
<DropdownMenuItem onClick={() => act.setViewMode("gantt")}>
<ChartGantt />
{t(($) => $.view.gantt)}
</DropdownMenuItem>
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -77,9 +77,24 @@
"view": {
"tooltip_board": "Board view",
"tooltip_list": "List view",
"tooltip_gantt": "Gantt view",
"section": "View",
"board": "Board",
"list": "List"
"list": "List",
"gantt": "Gantt"
},
"gantt": {
"header_issue": "Issue",
"zoom_day": "Day",
"zoom_week": "Week",
"zoom_month": "Month",
"show_completed": "Show completed",
"empty": "No issues to chart.",
"no_scheduled": "No issues have a start or due date yet — set one to see it on the timeline.",
"unscheduled_section": "Unscheduled · {{count}}",
"pagination_warning": "{{hidden}} more issue(s) haven't been loaded — the schedule is incomplete.",
"load_all": "Load all",
"inverted_dates_warning": "Start date is after due date — bar shown from min to max."
},
"actor_issues": {
"scope": {

View File

@@ -76,9 +76,24 @@
"view": {
"tooltip_board": "看板视图",
"tooltip_list": "列表视图",
"tooltip_gantt": "甘特图",
"section": "视图",
"board": "看板",
"list": "列表"
"list": "列表",
"gantt": "甘特图"
},
"gantt": {
"header_issue": "Issue",
"zoom_day": "日",
"zoom_week": "周",
"zoom_month": "月",
"show_completed": "显示已完成",
"empty": "暂无可展示的 issue。",
"no_scheduled": "暂无 issue 设置了开始或截止日期 — 设置后会显示在时间轴上。",
"unscheduled_section": "未排期 · {{count}}",
"pagination_warning": "还有 {{hidden}} 个 issue 未加载 — 排期可能不完整。",
"load_all": "加载全部",
"inverted_dates_warning": "开始日期晚于截止日期 — 已按区间最小/最大端绘制。"
},
"actor_issues": {
"scope": {

View File

@@ -12,8 +12,15 @@ import { projectDetailOptions } from "@multica/core/projects/queries";
import { useUpdateProject, useDeleteProject } from "@multica/core/projects/mutations";
import { pinListOptions } from "@multica/core/pins";
import { useCreatePin, useDeletePin } from "@multica/core/pins";
import { myIssueAssigneeGroupsOptions, myIssueListOptions, childIssueProgressOptions, type AssigneeGroupedIssuesFilter, type MyIssuesFilter } from "@multica/core/issues/queries";
import { useUpdateIssue } from "@multica/core/issues/mutations";
import {
myIssueAssigneeGroupsOptions,
myIssueListOptions,
myIssueListPaginationOptions,
childIssueProgressOptions,
type AssigneeGroupedIssuesFilter,
type MyIssuesFilter,
} from "@multica/core/issues/queries";
import { useLoadAllRemaining, useUpdateIssue } from "@multica/core/issues/mutations";
import { useModalStore } from "@multica/core/modals";
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
import { useWorkspaceId } from "@multica/core/hooks";
@@ -33,6 +40,7 @@ import { ProjectResourcesSection } from "./project-resources-section";
import { IssuesHeader } from "../../issues/components/issues-header";
import { BoardView } from "../../issues/components/board-view";
import { ListView } from "../../issues/components/list-view";
import { GanttView, type GanttPaginationProps } from "../../issues/components/gantt-view";
import { BatchActionToolbar } from "../../issues/components/batch-action-toolbar";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { Button } from "@multica/ui/components/ui/button";
@@ -107,6 +115,7 @@ function ProjectIssuesContent({
assigneeGroupFilter,
scope,
filter,
ganttPagination,
}: {
projectId: string;
projectIssues: Issue[];
@@ -115,6 +124,7 @@ function ProjectIssuesContent({
assigneeGroupFilter?: AssigneeGroupedIssuesFilter;
scope: string;
filter: MyIssuesFilter;
ganttPagination: GanttPaginationProps | undefined;
}) {
const { t } = useT("projects");
const wsId = useWorkspaceId();
@@ -185,7 +195,7 @@ function ProjectIssuesContent({
return (
<div className="flex flex-col flex-1 min-h-0">
{viewMode === "board" ? (
{viewMode === "board" && (
<BoardView
issues={assigneeGroups ? projectIssues : issues}
assigneeGroups={assigneeGroups}
@@ -199,7 +209,8 @@ function ProjectIssuesContent({
myIssuesFilter={filter}
projectId={projectId}
/>
) : (
)}
{viewMode === "list" && (
<ListView
issues={issues}
visibleStatuses={visibleStatuses}
@@ -209,6 +220,9 @@ function ProjectIssuesContent({
projectId={projectId}
/>
)}
{viewMode === "gantt" && (
<GanttView issues={issues} pagination={ganttPagination} />
)}
</div>
);
}
@@ -257,13 +271,36 @@ function ProjectIssuesSurface({
...assigneeGroupsOptions,
enabled: usesAssigneeBoard,
});
// Subscribes to the same cache as statusIssuesQuery and just selects a
// pagination summary instead of the flat issue array — used by Gantt to
// warn when issues are hidden behind unfetched pages (Board/List have
// per-column load-more affordances; Gantt does not).
const paginationQuery = useQuery({
...myIssueListPaginationOptions(wsId, scope, filter),
enabled: !usesAssigneeBoard,
});
const { loadAll, isLoading: isLoadingMore } = useLoadAllRemaining({ scope, filter });
const projectIssues = usesAssigneeBoard
? (assigneeGroupsQuery.data?.groups.flatMap((group) => group.issues) ?? [])
: (statusIssuesQuery.data ?? []);
// Gantt can't paginate per-column, so we surface a banner + Load-all
// affordance when the cache is missing pages. Skip when the surface is
// using the assignee-grouped query — that uses a different cache shape.
const ganttPagination: GanttPaginationProps | undefined =
!usesAssigneeBoard && paginationQuery.data
? {
loaded: paginationQuery.data.loaded,
total: paginationQuery.data.total,
hasMore: paginationQuery.data.hasMore,
isLoadingMore,
onLoadAll: loadAll,
}
: undefined;
return (
<>
<IssuesHeader scopedIssues={projectIssues} />
<IssuesHeader scopedIssues={projectIssues} allowGantt />
<ProjectIssuesContent
projectId={projectId}
projectIssues={projectIssues}
@@ -272,6 +309,7 @@ function ProjectIssuesSurface({
assigneeGroupFilter={usesAssigneeBoard ? assigneeGroupFilter : undefined}
scope={scope}
filter={filter}
ganttPagination={ganttPagination}
/>
<BatchActionToolbar />
</>