From a8b6f9f9cecd12bb11cfb7fa8d68dc3d5efb7a89 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Fri, 24 Apr 2026 03:01:54 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=A3=D0=9D=D0=9A=D0=A6=D0=98=D0=98=20-?= =?UTF-8?q?=20=D0=9C=D0=95=D0=96=D0=9F=D0=A0=D0=9E=D0=95=D0=9A=D0=A2=D0=9D?= =?UTF-8?q?=D0=90=D0=AF=20=D0=9A=D0=9E=D0=9C=D0=9C=D0=A3=D0=9D=D0=98=D0=9A?= =?UTF-8?q?=D0=90=D0=A6=D0=98=D0=AF:=20=D0=BA=D0=B0=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B4=D0=B0=D1=80=D0=BD=D0=B0=D1=8F=20=D0=BF=D1=80=D0=B8=D0=B2?= =?UTF-8?q?=D1=8F=D0=B7=D0=BA=D0=B0=20=D0=93=D0=B0=D0=BD=D1=82=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B3=D0=BB=D0=B0=D0=B2=D0=BD=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gantt-chart/preview/timeline-preview.tsx | 326 ++++++++++++++++++ .../components/home/home-gantt-preview.tsx | 247 ++----------- plane-src/apps/web/styles/globals.css | 41 ++- 3 files changed, 399 insertions(+), 215 deletions(-) create mode 100644 plane-src/apps/web/core/components/gantt-chart/preview/timeline-preview.tsx diff --git a/plane-src/apps/web/core/components/gantt-chart/preview/timeline-preview.tsx b/plane-src/apps/web/core/components/gantt-chart/preview/timeline-preview.tsx new file mode 100644 index 0000000..93226d0 --- /dev/null +++ b/plane-src/apps/web/core/components/gantt-chart/preview/timeline-preview.tsx @@ -0,0 +1,326 @@ +/** + * Copyright (c) 2023-present Plane Software, Inc. and contributors + * SPDX-License-Identifier: AGPL-3.0-only + * See the LICENSE file for details. + */ + +import { useMemo, useState } from "react"; +import { CalendarDays, Filter, SlidersHorizontal } from "lucide-react"; +import type { ChartDataType, IGanttBlock } from "@plane/types"; +import { cn } from "@plane/utils"; +import { getItemPositionWidth } from "@/components/gantt-chart/views/helpers"; + +const DAY_IN_MS = 24 * 60 * 60 * 1000; +const GANTT_RANGES = ["Live", "1D", "1W", "1M"] as const; +const GANTT_LABEL_COLUMN_WIDTH = 160; +const GANTT_LABEL_GAP = 16; +const GANTT_CANVAS_PADDING = 32; + +export type TGanttPreviewRange = (typeof GANTT_RANGES)[number]; + +export type TGanttTimelinePreviewItem = { + id: string; + name: string; + identifier: string; + sort_order?: number | null; + start_date?: string | null; + target_date?: string | null; +}; + +type TGanttTimelinePreviewProps = { + emptyMessage?: string; + isLoading?: boolean; + items: TGanttTimelinePreviewItem[]; + locale: string; + subtitle?: string; + title: string; +}; + +type TRangeSettings = { + dayWidth: number; + horizonDays: number; + paddingAfterDays: number; + paddingBeforeDays: number; + tickStepDays: number; +}; + +type TGanttPreviewBlock = TGanttTimelinePreviewItem & { + left: number; + tone: "accent" | "muted" | "white"; + width: number; +}; + +const RANGE_SETTINGS: Record = { + Live: { + dayWidth: 76, + horizonDays: 14, + paddingAfterDays: 3, + paddingBeforeDays: 2, + tickStepDays: 1, + }, + "1D": { + dayWidth: 120, + horizonDays: 1, + paddingAfterDays: 1, + paddingBeforeDays: 1, + tickStepDays: 1, + }, + "1W": { + dayWidth: 72, + horizonDays: 7, + paddingAfterDays: 2, + paddingBeforeDays: 2, + tickStepDays: 1, + }, + "1M": { + dayWidth: 34, + horizonDays: 30, + paddingAfterDays: 7, + paddingBeforeDays: 4, + tickStepDays: 5, + }, +}; + +const startOfDay = (date: Date) => { + const nextDate = new Date(date); + nextDate.setHours(0, 0, 0, 0); + return nextDate; +}; + +const addDays = (date: Date, days: number) => { + const nextDate = new Date(date); + nextDate.setDate(nextDate.getDate() + days); + return nextDate; +}; + +const getDateFromValue = (value?: string | null) => { + if (!value) return undefined; + + const date = new Date(value); + if (Number.isNaN(date.getTime())) return undefined; + + return startOfDay(date); +}; + +const getDaysBetween = (startDate: Date, endDate: Date) => + Math.max(Math.round((startOfDay(endDate).getTime() - startOfDay(startDate).getTime()) / DAY_IN_MS), 0); + +const getBlockTone = (block: IGanttBlock, index: number): TGanttPreviewBlock["tone"] => { + const today = startOfDay(new Date()); + const startDate = getDateFromValue(block.start_date); + const targetDate = getDateFromValue(block.target_date); + + if (targetDate && targetDate < today) return "white"; + if (startDate && targetDate && startDate <= today && targetDate >= today) return "accent"; + + return index % 3 === 0 ? "accent" : index % 3 === 1 ? "white" : "muted"; +}; + +const getTimelineBounds = (items: TGanttTimelinePreviewItem[], settings: TRangeSettings) => { + const today = startOfDay(new Date()); + const itemDates = items.flatMap((item) => [getDateFromValue(item.start_date), getDateFromValue(item.target_date)]); + const validDates = itemDates.filter((date): date is Date => !!date); + + const minTime = Math.min(today.getTime(), ...validDates.map((date) => date.getTime())); + const maxTime = Math.max(addDays(today, settings.horizonDays).getTime(), ...validDates.map((date) => date.getTime())); + + return { + endDate: addDays(new Date(maxTime), settings.paddingAfterDays), + startDate: addDays(new Date(minTime), -settings.paddingBeforeDays), + today, + }; +}; + +const getTimelineTicks = (startDate: Date, endDate: Date, dayWidth: number, stepDays: number, locale: string) => { + const formatter = new Intl.DateTimeFormat(locale || "ru-RU", { + day: "numeric", + month: "short", + }); + const days = getDaysBetween(startDate, endDate); + + return Array.from({ length: Math.floor(days / stepDays) + 1 }, (_, index) => { + const date = addDays(startDate, index * stepDays); + return { + id: date.toISOString(), + label: formatter.format(date).toUpperCase(), + left: getDaysBetween(startDate, date) * dayWidth, + }; + }); +}; + +export function GanttTimelinePreview(props: TGanttTimelinePreviewProps) { + const { emptyMessage, isLoading = false, items, locale, subtitle, title } = props; + const [activeRange, setActiveRange] = useState("Live"); + const [isCompactMode, setIsCompactMode] = useState(false); + const [showCompleteBlocksOnly, setShowCompleteBlocksOnly] = useState(false); + + const timeline = useMemo(() => { + const settings = RANGE_SETTINGS[activeRange]; + const { endDate, startDate, today } = getTimelineBounds(items, settings); + const totalDays = getDaysBetween(startDate, endDate) + 1; + const timelineWidth = Math.max(totalDays * settings.dayWidth, 620); + const canvasWidth = GANTT_LABEL_COLUMN_WIDTH + GANTT_LABEL_GAP + timelineWidth + GANTT_CANVAS_PADDING; + const chartData: ChartDataType = { + key: activeRange, + i18n_title: activeRange, + data: { + approxFilterRange: 0, + currentDate: today, + dayWidth: settings.dayWidth, + endDate, + startDate, + }, + }; + + const blocks = items + .map((item, index) => { + if (showCompleteBlocksOnly && (!item.start_date || !item.target_date)) return undefined; + + const block: IGanttBlock = { + data: item, + id: item.id, + name: item.name, + sort_order: item.sort_order ?? undefined, + start_date: item.start_date ?? undefined, + target_date: item.target_date ?? undefined, + }; + const position = getItemPositionWidth(chartData, block); + if (!position) return undefined; + + const overflowLeft = Math.min(position.marginLeft, 0); + const left = Math.max(position.marginLeft, 0); + const width = Math.max(Math.min(position.width + overflowLeft, timelineWidth - left), settings.dayWidth * 0.78); + + return { + ...item, + left, + tone: getBlockTone(block, index), + width, + }; + }) + .filter((block): block is TGanttPreviewBlock => !!block); + + return { + blocks, + canvasWidth, + ticks: getTimelineTicks(startDate, endDate, settings.dayWidth, settings.tickStepDays, locale), + timelineWidth, + todayLeft: getDaysBetween(startDate, today) * settings.dayWidth, + }; + }, [activeRange, items, locale, showCompleteBlocksOnly]); + + const hiddenItemsCount = Math.max(items.length - timeline.blocks.length, 0); + + return ( +
+
+
+
+ +
+
+
{title}
+ {subtitle &&
{subtitle}
} +
+
+ +
+ {GANTT_RANGES.map((item) => ( + + ))} + + +
+
+ +
+
+
+ +
+ ); +} diff --git a/plane-src/apps/web/core/components/home/home-gantt-preview.tsx b/plane-src/apps/web/core/components/home/home-gantt-preview.tsx index 012a781..b8a5019 100644 --- a/plane-src/apps/web/core/components/home/home-gantt-preview.tsx +++ b/plane-src/apps/web/core/components/home/home-gantt-preview.tsx @@ -4,17 +4,18 @@ * See the LICENSE file for details. */ -import { useMemo, useState } from "react"; -import { CalendarDays, Filter, SlidersHorizontal } from "lucide-react"; import useSWR from "swr"; import { useTranslation } from "@plane/i18n"; import type { TIssue, TProjectAnalyticsCount } from "@plane/types"; -import { cn } from "@plane/utils"; +import { + GanttTimelinePreview, + type TGanttTimelinePreviewItem, +} from "@/components/gantt-chart/preview/timeline-preview"; import { IssueService } from "@/services/issue"; -import { getCompletionRate, type THomeProjectData } from "./home.utils"; +import type { THomeProjectData } from "./home.utils"; const issueService = new IssueService(); -const GANTT_PREVIEW_LIMIT = 6; +const GANTT_PREVIEW_LIMIT = 30; const GANTT_PREVIEW_CURSOR = `${GANTT_PREVIEW_LIMIT}:0:0`; type HomeGanttPreviewProps = { @@ -23,85 +24,31 @@ type HomeGanttPreviewProps = { workspaceSlug: string; }; -type TGanttPreviewItem = { - id: string; - label: string; - subtitle: string; - start: number; - width: number; - tone: "accent" | "muted" | "white"; +const getIssueResults = (response: unknown): TIssue[] => { + if (Array.isArray(response)) return response as TIssue[]; + if (response && typeof response === "object" && "results" in response) { + const results = (response as { results?: unknown }).results; + if (Array.isArray(results)) return results as TIssue[]; + } + + return []; }; -const GANTT_RANGES = ["Live", "1D", "1W", "1M"] as const; -type TGanttRange = (typeof GANTT_RANGES)[number]; - -const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); - -const buildSyntheticItems = (project: THomeProjectData | undefined, analytics: TProjectAnalyticsCount | undefined) => { - const completionRate = getCompletionRate(analytics); - const openIssues = Math.max((analytics?.total_issues ?? 0) - (analytics?.completed_issues ?? 0), 0); - const baseName = project?.identifier ?? "NODE"; - - return [ - { - id: "synthetic-approval", - label: "Согласование расходов", - subtitle: `${baseName} / финконтроль`, - start: 6, - width: clamp(34 + completionRate * 0.22, 26, 58), - tone: "accent", - }, - { - id: "synthetic-docs", - label: "Контроль документов", - subtitle: `${baseName} / внешний обмен`, - start: 22, - width: clamp(28 + openIssues * 2, 24, 54), - tone: "white", - }, - { - id: "synthetic-sync", - label: "Синхронизация статусов", - subtitle: `${baseName} / внутренний контур`, - start: 42, - width: 36, - tone: "muted", - }, - { - id: "synthetic-close", - label: "Закрытие остатка", - subtitle: `${baseName} / итог недели`, - start: 58, - width: 28, - tone: "accent", - }, - ] satisfies TGanttPreviewItem[]; -}; - -const buildIssueItems = (issues: TIssue[], project: THomeProjectData): TGanttPreviewItem[] => - issues.slice(0, GANTT_PREVIEW_LIMIT).map((issue, index) => { - const createdDate = Date.parse(issue.created_at ?? "") || Date.now(); - const targetDate = Date.parse(issue.target_date ?? "") || createdDate + (index + 3) * 24 * 60 * 60 * 1000; - const durationDays = Math.max((targetDate - createdDate) / (24 * 60 * 60 * 1000), 1); - const start = clamp((index * 13 + durationDays * 2) % 68, 4, 72); - const width = clamp(20 + durationDays * 5, 22, 48); - - return { - id: issue.id, - label: issue.name, - subtitle: `${project.identifier}-${issue.sequence_id ?? index + 1}`, - start, - width, - tone: index % 3 === 0 ? "accent" : index % 3 === 1 ? "white" : "muted", - }; - }); +const buildPreviewItems = (issues: TIssue[], project: THomeProjectData | undefined): TGanttTimelinePreviewItem[] => + issues.map((issue, index) => ({ + id: issue.id, + identifier: project + ? `${project.identifier}-${issue.sequence_id ?? index + 1}` + : `#${issue.sequence_id ?? index + 1}`, + name: issue.name, + sort_order: issue.sort_order, + start_date: issue.start_date, + target_date: issue.target_date, + })); export function HomeGanttPreview(props: HomeGanttPreviewProps) { - const { analytics, project, workspaceSlug } = props; + const { project, workspaceSlug } = props; const { currentLocale } = useTranslation(); - const [activeRange, setActiveRange] = useState("Live"); - const [isCompactMode, setIsCompactMode] = useState(false); - const [isFilterActive, setIsFilterActive] = useState(false); const { data: issueResponse, isLoading } = useSWR( project ? `HOME_GANTT_PREVIEW_${workspaceSlug}_${project.id}` : null, @@ -120,139 +67,17 @@ export function HomeGanttPreview(props: HomeGanttPreviewProps) { } ); - const timelineLabels = useMemo(() => { - const locale = currentLocale || "ru-RU"; - const dayFormatter = new Intl.DateTimeFormat(locale, { - day: "numeric", - month: "short", - }); - const hourFormatter = new Intl.DateTimeFormat(locale, { - hour: "2-digit", - minute: "2-digit", - }); - - if (activeRange === "Live" || activeRange === "1D") { - const date = new Date(); - const step = activeRange === "Live" ? 2 : 3; - return Array.from({ length: activeRange === "Live" ? 8 : 9 }, (_, index) => { - const labelDate = new Date(date); - labelDate.setHours(date.getHours() + index * step); - return hourFormatter.format(labelDate); - }); - } - - return Array.from({ length: activeRange === "1W" ? 7 : 8 }, (_, index) => { - const date = new Date(); - date.setDate(date.getDate() + index * (activeRange === "1W" ? 1 : 4)); - return dayFormatter.format(date); - }); - }, [activeRange, currentLocale]); - - const previewItems = useMemo(() => { - const issues = issueResponse?.results; - if (project && Array.isArray(issues) && issues.length > 0) return buildIssueItems(issues, project); - return buildSyntheticItems(project, analytics); - }, [analytics, issueResponse, project]); - - const visibleItems = isFilterActive ? previewItems.filter((item) => item.tone !== "muted") : previewItems; - const timelineWidth = `${Math.max(timelineLabels.length * 168 + 240, 1080)}px`; + const issues = getIssueResults(issueResponse); + const previewItems = buildPreviewItems(issues, project); return ( -
-
-
-
- -
-
-
Календарное окно Ганта
-
- {project ? `${project.name} / ближайший рабочий горизонт` : "Выберите проект для живого окна"} -
-
-
- -
- {GANTT_RANGES.map((item) => ( - - ))} - - -
-
- -
-
-
- - -
- {isLoading - ? Array.from({ length: 4 }, (_, index) => ( -
- )) - : visibleItems.map((item) => ( -
-
-
{item.label}
-
{item.subtitle}
-
-
-
-
-
- ))} -
-
-
-
-
+ ); } diff --git a/plane-src/apps/web/styles/globals.css b/plane-src/apps/web/styles/globals.css index 5df8802..0ccf888 100644 --- a/plane-src/apps/web/styles/globals.css +++ b/plane-src/apps/web/styles/globals.css @@ -1782,11 +1782,12 @@ position: absolute; inset: 1rem 1rem 1rem 11.5rem; z-index: 1; - display: grid; - gap: 0; } .nodedc-home-gantt-grid-column { + position: absolute; + top: 0; + bottom: 0; min-height: 22rem; border-left: 1px solid rgba(255, 255, 255, 0.04); padding-left: 0.75rem; @@ -1797,9 +1798,17 @@ text-transform: uppercase; } + .nodedc-home-gantt-today-line { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + background: rgba(var(--nodedc-card-active-rgb), 0.42); + box-shadow: 0 0 18px rgba(var(--nodedc-card-active-rgb), 0.28); + } + .nodedc-home-gantt-row { display: grid; - grid-template-columns: minmax(7.5rem, 10rem) minmax(0, 1fr); align-items: center; gap: 1rem; min-height: 3.45rem; @@ -1846,6 +1855,31 @@ background: rgba(255, 255, 255, 0.18); } + .nodedc-home-gantt-empty { + display: flex; + min-height: 14rem; + max-width: 32rem; + flex-direction: column; + justify-content: center; + border-radius: 1.6rem; + background: rgba(0, 0, 0, 0.2); + padding: 1.5rem; + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.018) !important; + } + + .nodedc-home-gantt-footnote { + position: sticky; + left: 1rem; + z-index: 2; + margin-top: 1rem; + width: fit-content; + border-radius: 999px; + background: rgba(255, 255, 255, 0.06); + padding: 0.5rem 0.8rem; + color: var(--text-color-secondary); + font-size: 0.72rem; + } + @media (max-width: 767px) { .nodedc-home-gantt-card { min-height: auto; @@ -1875,7 +1909,6 @@ } .nodedc-home-gantt-row { - grid-template-columns: minmax(7rem, 8.25rem) minmax(0, 1fr); gap: 0.75rem; min-height: 3.9rem; }