mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 05:46:58 +02:00
* fix(issues): anchor the working chip on issues the filter actually shows (MUL-4884)
The header chip showed three units at once: the number counted distinct
issues, the avatar stack counted agents (with a rival "+N"), and the hover
card counted tasks — under a label with no noun at all. Each figure was
self-consistent; together they read as a miscount. c4209ec7c flipped the
number from agents to issues but left the other three surfaces on their old
units.
The chip now says one thing — "N issues in progress" — where N is the number
of rows clicking it leaves.
Data layer:
- The count is no longer re-derived from the task snapshot. The surface
exposes `workingScopeIssues`: the render pipeline's own output with
`workingOnly` forced on, so "chip count === row count" holds by
construction instead of by convention. It previously counted running
issue_ids against the PRE-filter set, so any active status/assignee/label
filter — or a sub-issue hidden by the display toggle — made the chip
disagree with the list it was filtering.
- Chat/autopilot tasks carry issue_id "" (not null). They used to collapse
into one bucket and read as a phantom issue, inflating the count by exactly
one whenever any were running. They are now bucketed out and disclosed.
- The list loads one page per status (50), so running work can exist past the
window. The count stays list-anchored — counting rows a click cannot show
would break the chip's whole promise — and the gap is stated in the hover
card rather than dropped silently.
UI:
- Avatar stack is ambience, not a statistic: no "+N"; the tail fades and the
exact roster lives in the hover card.
- Hover card groups rows by issue, names both counted units in its header
("3 issues in progress · 4 tasks"), and footnotes anything excluded — only
when non-zero.
- Colour is two-step: idle activity is a brand tint, the filled state is
reserved for "filter is ON".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): scope the working chip to the rows swimlane and gantt actually draw (MUL-4884)
Review found two modes where `workingScopeIssues` still came from a
different set than the one on screen — the exact drift this change set is
meant to remove.
Swimlane: scoped to the statusless `swimlaneIssues`, but SwimLaneView draws
its cards from `issues` (status filter applied) and only uses the statusless
set for LANE DISCOVERY. A status-filtered swimlane counted rows the canvas
never drew. Swimlane needs no branch at all — it renders the flat filtered
list like board and list do — so the special case is gone rather than
corrected.
Gantt: the canvas applies two rules of its own before drawing — a row needs a
date to be placed, and done/cancelled hide unless `ganttShowCompleted` is on
— so a done issue with a running agent counted toward the chip while the
canvas refused to draw it.
Those rules now live in the surface (`ganttCanvasRows`) instead of privately
inside GanttView, and both `filteredGanttIssues` and the working scope go
through them. Mirroring the rules in a second place would have reproduced the
original bug with extra steps; hoisting them means the chip narrows the same
set the canvas draws, by construction. GanttView goes back to being a
renderer: it orders rows, it does not decide which ones exist.
Regression tests cover both, and each was confirmed to fail against the code
it guards: swimlane asserts the statusless lane source still holds the wider
set, so a revert fails loudly rather than passing by luck; gantt covers the
hidden done row, the undated row, and the show-completed toggle widening both
the canvas and the scope together.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* feat(issues): anchor the working chip on agents, and give it real colour tiers (MUL-4884)
Follows the approved final mock. Two changes, one root cause each.
SEMANTICS: the chip now counts agents, not issues.
The original bug was "+1" (agents) sitting next to "3" (issues) — two units
in one control. Anchoring on issues fixed which number was authoritative but
left the units mismatched, so the avatar stack had to lose its +N and the
label had to grow a noun to suppress the contradiction. Counting agents makes
the number and the heads beside it the same list: the mismatch is gone rather
than managed. It also settles the subject — only agents emit a runtime
signal, so "N agents working" cannot be misread as "N issues someone is
working on", which "N issues in progress" invited.
Accepted trade-off: the number no longer predicts the row count of the click.
One agent on two issues reads "1 agent working" and opens two rows. The chip
answers WHO, the list answers WHERE — different questions, so they don't
compete, and the hover card's issue grouping shows the mapping. The
workingScopeIssues pipeline stays: it still decides WHICH agents count.
The hover card drops both "not counted" footnotes. They explained an absence
the user never perceived — chat/autopilot runs leave no row, no head, no
indicator on this page — so they invented a discrepancy instead of resolving
one. The empty-issue_id guard stays: that is data correctness (those agents
aren't on any issue), independent of whether we narrate it.
Avatar overflow returns to the component's standard +N badge; the fade
variant is deleted. With an agent-anchored number, "3 shown + 1 = 4"
corroborates the text instead of competing with it.
COLOUR: three tiers, each a self-contained Button variant.
Layering brand classes over `outline` could never work in dark mode:
`outline` ships dark:bg-input/30 / dark:border-input / dark:hover:bg-input/50,
tailwind-merge keeps them (different modifier group), and they win the
cascade — `dark` compiles to `&:is(.dark *)`, so they outrank a bare
.bg-brand on specificity, not just order. Both brand tiers were dead in dark;
--brand-foreground equals --foreground there, so filter-on and filter-off
rendered pixel-identical.
New `brand` / `brandSubtle` variants own every state instead: hover, pressed,
and aria-expanded pinned to the hover value so opening the popover doesn't
read as a colour change. `brand` needs no dark: at all — the token flips per
theme. `brandSubtle` runs one notch hotter in dark, where the same alpha
reads weaker.
Verified against the compiled stylesheet rather than by asserting class
strings — a string assertion is what let the dark bug through. A cascade
check resolves each tier x {default,hover,pressed,popover-open} x
{light,dark} by specificity and source order; all 16 brand cells land on
their intended notch, identical in both themes for `brand`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
* fix(issues): stop the empty-state muted text from overriding the brand tier (MUL-4884)
Review caught the colour rule breaking itself, and the test that was supposed
to guard it being unable to.
`filter ON + 0 agents` is a real state — the filter stays on after the last
agent finishes. There the variant is `brand`, and the chip appended
`text-muted-foreground` for the empty state regardless of the filter.
tailwind-merge keeps the last class in a group and `className` is merged
after the variant, so the muted grey WON: grey text on a brand-blue fill. The
irony is exact — this change set exists because colour classes in `className`
lose to a variant's `dark:` chain, and here one beat the variant instead.
Either way the lesson is the same: a tier's colours only ever come from its
variant. `chipAppearance` now makes that a rule with one gated exception
instead of an inline ternary, and says why.
The variant assertion could not catch this: the variant IS `brand` while the
text is overridden. That is the same shape of false confidence as the class
-string assertion it replaced, so this commit brings the cascade check into
the repo instead of leaving it as something a commit message claimed.
`apps/web/app/brand-variant-cascade.test.ts` compiles the real globals.css —
web's and desktop's, since each defines its own `dark` variant — and resolves
what a browser would paint for the merged class strings: filter declarations
matching the element's classes and state, then take the winner by
specificity, then source order. It asserts both tiers across default / hover
/ pressed / popover-open x light / dark, and pins the two ways the colour has
actually been lost as executable failures: layering brand over `outline`
(dark:bg-input/30 wins by specificity) and appending a colour class (wins by
merge order).
Both new guards were confirmed to fail against the code they guard: reverting
the gate fails the chipAppearance test; giving `brand` a dark: rule fails the
cascade test in both bundles.
Also refreshes the comments still describing the retired issue-anchored
invariant ("chip count === row count"). The scope pipeline they describe is
unchanged and still load-bearing — it decides WHICH agents count — but the
chip's number is agents, so it is no longer this list's length.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
581 lines
20 KiB
TypeScript
581 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useRef } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
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 { dateOnlyToUTCDate } from "@multica/core/issues/date";
|
|
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);
|
|
}
|
|
|
|
// Issue dates arrive as date-only "YYYY-MM-DD" strings (calendar days). Anchor
|
|
// each to UTC midnight so the bar lands on exactly that day, independent of the
|
|
// viewer's timezone. See @multica/core/issues/date.
|
|
function parseDay(iso: string | null): Date | null {
|
|
return dateOnlyToUTCDate(iso);
|
|
}
|
|
|
|
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="sm"
|
|
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>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GanttView — public component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function GanttView({ issues }: { issues: Issue[] }) {
|
|
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];
|
|
|
|
// `issues` is already the canvas set: the surface applies the shared
|
|
// filters, drops undated rows, and honours `ganttShowCompleted` before
|
|
// handing it over (see `ganttCanvasRows` in use-issue-surface-data.ts).
|
|
// Those rules used to live here, which meant the header chip could count
|
|
// rows this canvas would never draw (MUL-4884). Keep this view a renderer:
|
|
// it orders rows, it does not decide which ones exist.
|
|
const scheduled = useMemo(() => {
|
|
// "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(issues, sortField, sortDirection);
|
|
}, [issues, sortBy, sortDirection]);
|
|
|
|
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]);
|
|
|
|
if (scheduled.length === 0) {
|
|
return (
|
|
<div className="flex-1 min-h-0 flex items-center justify-center text-sm text-muted-foreground">
|
|
{t(($) => $.gantt.empty)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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>
|
|
|
|
{/* 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 */}
|
|
<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>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|