diff --git a/docs_prod/2_voicetasker/VOICETASKER_TECH.md b/docs_prod/2_voicetasker/VOICETASKER_TECH.md index 24eaaea..2b9a5c3 100644 --- a/docs_prod/2_voicetasker/VOICETASKER_TECH.md +++ b/docs_prod/2_voicetasker/VOICETASKER_TECH.md @@ -252,12 +252,15 @@ MVP-правило: Система: -1. берет последние voice-действия пользователя в текущем workspace; -2. находит последную созданную/обновленную voice-задачу; -3. показывает preview изменения, если confidence низкий; -4. меняет `Issue.target_date`; -5. сохраняет `due_time` в description note / parsed JSON; -6. пишет новое действие в session-backed memory. +1. берет последние только реально примененные voice-действия пользователя в текущем workspace; +2. игнорирует parsed-сессии без `created_task`/`updated_task`, чтобы модель не цеплялась за старые неудачные черновики; +3. если transcript явно задает исходный проект ("из Бухгалтерии", "последнюю добавленную в Бухгалтерию"), сначала ищет последнюю voice-задачу в этом проекте; +4. если исходный проект не назван, сначала ищет последнюю voice-задачу в текущем открытом проекте; +5. затем использует последнюю примененную voice-задачу workspace как общий fallback; +6. показывает preview изменения, если confidence низкий; +7. меняет `Issue.target_date`; +8. сохраняет `due_time` в description note / parsed JSON; +9. пишет новое действие в session-backed memory. Если пользователь говорит "переложи последнюю задачу в проект X", это остается `update_task`, но backend должен: @@ -882,6 +885,7 @@ Return JSON only. 6. если confidence низкий - preview с ручным выбором; 7. если transcript содержит явную маршрутизацию проекта, но model `project_hint` указывает на проект, которого нет в transcript, auto-commit блокируется; 8. при наличии source/destination фразы выбирать destination project: "из Бухгалтерии в Менеджмент" -> target `Менеджмент`, "из Менеджмента в Бухгалтерию" -> target `Бухгалтерия`. +9. глагол "перенеси/перенести" рядом со сроком/датой считается date update, а не project routing, если нет project/контур-маркера или явного project destination. Не зашивать термин "контур" как обязательный. Для NODE DC это важный UX-термин, но технически это обычный `Project`. @@ -947,11 +951,28 @@ MVP: - `N` дней/недель/месяцев/лет назад; - сложные интервалы: "два месяца и две недели"; - числительные цифрами и частые русские словоформы: "2 недели", "две недели", "пару дней"; +- абсолютные русские даты: "1 мая 2026 года", "30 апреля"; +- числовые даты: "01.05.2026", "1/05/26"; - защита от ложных матчей внутри слов: "последней" не считается как "дней"; +- защита от трактовки года абсолютной даты как относительного интервала: "2026 года" не считается как "+2026 лет"; - конкретная дата; - конкретное время как `due_time` note. -Date resolver обязан работать после OpenAI parser как deterministic fallback. Если модель уже вернула валидный `due_date`, backend его не переписывает. +Date resolver обязан работать после OpenAI parser как deterministic слой. Сначала резолвятся абсолютные даты из transcript; они могут переписать ошибочный `due_date` от модели. Затем обрабатываются относительные сдвиги вида "подвинь на 3 дня вперед" / "передвинь назад на 3 дня": backend может переписать `due_date`, даже если модель уже вернула дату, а база расчета берется из текущего `Issue.target_date`, а не из сегодняшней даты. Для фраз вида "через 3 дня" без маркера сдвига база остается текущей датой. + +### 10.4.1. Memory resolver + +`recent_voice_memory` для parser содержит только примененные voice-сессии, у которых есть доступная `target_task`. + +При backend commit: + +1. explicit issue key/issue id остается самым сильным указанием цели; +2. `target_memory_ref` на voice-сессию используется только если эта сессия реально связана с доступной задачей; +3. если transcript содержит общее указание "последняя/предыдущая/эта", backend не доверяет model-selected voice session ref и выбирает цель deterministic fallback-ом; +4. если ref ведет в parsed/no-op сессию, resolver переходит к deterministic fallback; +5. fallback сначала учитывает явно названный source project; +6. затем текущий project из `client_context.current_project_id`; +7. затем последнюю примененную voice-задачу workspace. ### 10.5. Voice task representation in Issue @@ -1201,6 +1222,7 @@ voice_task.error 23. Относительная дата "на две недели вперед" меняет `Issue.target_date` без ручного ввода ISO-даты. 24. Перенос "из Бухгалтерии в Менеджмент" и "из Менеджмента в Бухгалтерию" реально меняет `Issue.project_id` и `task_key`. 25. Voice-created task имеет `external_source=voice_tasker` и хранит исходный transcript в description. +26. Preview modal показывает transcript/description полностью без внутреннего scroll внутри текстовых блоков. --- diff --git a/plane-src/apps/api/plane/app/views/voice_tasker.py b/plane-src/apps/api/plane/app/views/voice_tasker.py index 3339e27..40c835e 100644 --- a/plane-src/apps/api/plane/app/views/voice_tasker.py +++ b/plane-src/apps/api/plane/app/views/voice_tasker.py @@ -113,6 +113,32 @@ VOICE_TASK_STATE_GROUP_HINTS = { } DATE_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2}$") TIME_PATTERN = re.compile(r"^\d{2}:\d{2}$") +VOICE_TASK_MONTHS = { + "январь": 1, + "января": 1, + "февраль": 2, + "февраля": 2, + "март": 3, + "марта": 3, + "апрель": 4, + "апреля": 4, + "май": 5, + "мая": 5, + "июнь": 6, + "июня": 6, + "июль": 7, + "июля": 7, + "август": 8, + "августа": 8, + "сентябрь": 9, + "сентября": 9, + "октябрь": 10, + "октября": 10, + "ноябрь": 11, + "ноября": 11, + "декабрь": 12, + "декабря": 12, +} VOICE_TASK_NUMBER_WORDS = { "один": 1, "одна": 1, @@ -152,6 +178,14 @@ VOICE_TASK_RELATIVE_DATE_PATTERN = re.compile( r"(?Pдень|дня|дней|сутки|суток|неделю|неделя|недели|недель|" r"месяц|месяца|месяцев|год|года|лет)(?![0-9a-zа-я])" ) +VOICE_TASK_MONTH_NAME_PATTERN = "|".join(sorted(VOICE_TASK_MONTHS.keys(), key=len, reverse=True)) +VOICE_TASK_ABSOLUTE_MONTH_DATE_PATTERN = re.compile( + rf"(?[0-3]?\d)\s+(?P{VOICE_TASK_MONTH_NAME_PATTERN})" + r"(?:\s+(?P\d{4}))?(?:\s+года?)?(?![0-9a-zа-я])" +) +VOICE_TASK_NUMERIC_DATE_PATTERN = re.compile( + r"(?[0-3]?\d)[./-](?P[01]?\d)(?:[./-](?P\d{2,4}))?(?!\d)" +) def normalize_audio_content_type(content_type): @@ -443,13 +477,15 @@ def transcript_has_project_routing_request(transcript): if not normalized: return False - normalized = normalized.lower() - return bool( - re.search( - r"(проект|контур|перелож|перенес|перемест|перекин|route|move\s+to\s+project|project)", - normalized, - ) + normalized = normalize_match_value(normalized) + if re.search(r"(проект|контур|route|move\s+to\s+project|project)", normalized): + return True + + has_transfer_verb = bool(re.search(r"(перелож|перенес|перемест|перекин|move)", normalized)) + has_due_marker = bool( + re.search(r"(срок|дат|дедлайн|deadline|завтра|сегодня|послезавтра|вчера|дн(я|ей|ь)|недел|месяц|год|лет)", normalized) ) + return has_transfer_verb and not has_due_marker def transcript_contains_project_hint(project_hint, transcript): @@ -461,6 +497,19 @@ def transcript_contains_project_hint(project_hint, transcript): return normalized_hint in normalized_transcript +def transcript_has_generic_memory_reference(transcript): + normalized_transcript = normalize_match_value(transcript) + if not normalized_transcript: + return False + + return bool( + re.search( + r"\b(последн\w*|предыдущ\w*|прошл\w*|эту|эта|этой|этот|ее|её|его|ту|той)\b", + normalized_transcript, + ) + ) + + def infer_voice_task_project_from_transcript(projects, transcript): normalized_transcript = normalize_match_value(transcript) if not normalized_transcript: @@ -490,7 +539,7 @@ def infer_voice_task_project_from_transcript(projects, transcript): elif has_transfer_intent and re.search(r"(в|во|на)\s*$", prefix): score = 0.99 - if score > best_score or (score == best_score and alias_length > best_alias_length): + if score > 0 and (score > best_score or (score == best_score and alias_length > best_alias_length)): best_project = project best_score = score best_alias_length = alias_length @@ -501,6 +550,54 @@ def infer_voice_task_project_from_transcript(projects, transcript): return serialize_resolved_project(best_project, best_score, "transcript_project_hint") +def infer_voice_task_source_project_from_transcript(projects, transcript): + normalized_transcript = normalize_match_value(transcript) + if not normalized_transcript: + return None + + has_transfer_intent = bool(re.search(r"(перелож|перенес|перемест|перекин|move|route)", normalized_transcript)) + best_project = None + best_score = 0.0 + best_alias_length = 0 + + for project in projects: + for candidate in get_project_alias_candidates(project): + normalized_candidate = normalize_match_value(candidate) + if len(normalized_candidate) < 3: + continue + + candidate_index = normalized_transcript.find(normalized_candidate) + if candidate_index < 0: + continue + + prefix = normalized_transcript[max(0, candidate_index - 56) : candidate_index] + alias_length = len(normalized_candidate) + score = 0.0 + + if re.search(r"(из|с|со|from|source)\s+(?:проекта\s+|контура\s+)?$", prefix): + score = 1.0 + elif re.search( + r"(добав\w*|созда\w*|постав\w*)\s+(?:задач\w+\s+)?(в|во|на)\s+(?:проекте\s+|проект\s+|контуре\s+|контур\s+)?$", + prefix, + ): + score = 0.95 + elif ( + not has_transfer_intent + and re.search(r"(в|во|на)\s+(?:проекте\s+|проект\s+|контуре\s+|контур\s+)?$", prefix) + ): + score = 0.9 + + if score > 0 and (score > best_score or (score == best_score and alias_length > best_alias_length)): + best_project = project + best_score = score + best_alias_length = alias_length + + if not best_project: + return None + + return best_project + + def get_text_match_score(query, candidates): normalized_query = normalize_match_value(query) if not normalized_query: @@ -811,6 +908,61 @@ def add_months_to_date(value, months): return date(year, month, day) +def build_voice_task_date(day, month, year, current_date, year_was_explicit=False): + try: + day = int(day) + month = int(month) + year = int(year) if year else current_date.year + if year < 100: + year += 2000 + candidate = date(year, month, day) + except (TypeError, ValueError): + return None + + if not year_was_explicit and candidate < current_date: + try: + candidate = date(current_date.year + 1, month, day) + except ValueError: + return None + + return candidate.isoformat() + + +def infer_voice_task_absolute_due_date(transcript, current_date): + normalized = normalize_match_value(transcript) + if normalized: + match = VOICE_TASK_ABSOLUTE_MONTH_DATE_PATTERN.search(normalized) + if match: + month = VOICE_TASK_MONTHS.get(match.group("month")) + result = build_voice_task_date( + day=match.group("day"), + month=month, + year=match.group("year"), + current_date=current_date, + year_was_explicit=bool(match.group("year")), + ) + if result: + return result + + raw_transcript = normalize_string(transcript) + if not raw_transcript: + return None + + match = VOICE_TASK_NUMERIC_DATE_PATTERN.search(raw_transcript.lower().replace("ё", "е")) + if match: + result = build_voice_task_date( + day=match.group("day"), + month=match.group("month"), + year=match.group("year"), + current_date=current_date, + year_was_explicit=bool(match.group("year")), + ) + if result: + return result + + return None + + def infer_voice_task_relative_due_date(transcript, current_date, target_issue=None): normalized = normalize_match_value(transcript) if not normalized: @@ -868,6 +1020,8 @@ def infer_voice_task_relative_due_date(transcript, current_date, target_issue=No elif unit.startswith("месяц"): shift_months += quantity elif unit.startswith("год") or unit == "лет": + if quantity >= 100: + continue shift_months += quantity * 12 if shift_days == 0 and shift_months == 0: @@ -875,7 +1029,22 @@ def infer_voice_task_relative_due_date(transcript, current_date, target_issue=No base_date = current_date source_date = getattr(target_issue, "target_date", None) - if source_date and any(marker in normalized for marker in ["подвин", "сдвин", "смест", "отлож", "раньше", "позже"]): + has_existing_due_shift = any( + marker in normalized + for marker in [ + "подвин", + "передвин", + "сдвин", + "смест", + "отлож", + "перенес", + "назад", + "вперед", + "раньше", + "позже", + ] + ) + if source_date and has_existing_due_shift: base_date = source_date result = add_months_to_date(base_date, shift_months * direction) if shift_months else base_date @@ -884,12 +1053,15 @@ def infer_voice_task_relative_due_date(transcript, current_date, target_issue=No def hydrate_voice_task_due_date(draft, transcript, client_context, user, workspace, target_issue=None): - if draft.get("due_date"): + current_date = get_voice_task_current_date(client_context, user, workspace) + absolute_due_date = infer_voice_task_absolute_due_date(transcript, current_date=current_date) + if absolute_due_date: + draft["due_date"] = absolute_due_date return inferred_due_date = infer_voice_task_relative_due_date( transcript=transcript, - current_date=get_voice_task_current_date(client_context, user, workspace), + current_date=current_date, target_issue=target_issue, ) if inferred_due_date: @@ -972,8 +1144,39 @@ def is_voice_task_issue_available(issue): return bool(issue and not issue.deleted_at and not issue.archived_at) -def resolve_voice_task_memory_target(workspace, user, draft, current_session=None): +def get_committed_voice_task_memory_sessions(workspace, user, current_session=None): + memory_sessions = ( + VoiceTaskSession.objects.filter( + workspace=workspace, + user=user, + status=VoiceTaskSession.Status.PARSED, + ) + .filter(Q(created_task__isnull=False) | Q(updated_task__isnull=False)) + .select_related("created_task", "created_task__project", "updated_task", "updated_task__project") + .order_by("-updated_at", "-created_at") + ) + if current_session: + memory_sessions = memory_sessions.exclude(id=current_session.id) + + return list(memory_sessions[: VOICE_TASK_MEMORY_LIMIT * 3]) + + +def find_latest_voice_task_issue(memory_sessions, project_id=None): + for memory_session in memory_sessions: + target_issue = get_voice_session_target_issue(memory_session) + if not is_voice_task_issue_available(target_issue): + continue + if project_id and str(target_issue.project_id) != str(project_id): + continue + return target_issue, memory_session + + return None, None + + +def resolve_voice_task_memory_target(workspace, user, draft, current_session=None, client_context=None, transcript=None): target_memory_ref = normalize_string(draft.get("target_memory_ref"), 80) + memory_sessions = get_committed_voice_task_memory_sessions(workspace, user, current_session=current_session) + generic_memory_reference = transcript_has_generic_memory_reference(transcript) if target_memory_ref: target_uuid = None @@ -989,7 +1192,7 @@ def resolve_voice_task_memory_target(workspace, user, draft, current_session=Non .first() ) target_issue = get_voice_session_target_issue(memory_session) - if target_issue: + if is_voice_task_issue_available(target_issue) and not generic_memory_reference: return target_issue, "target_memory_ref", memory_session target_issue = ( @@ -997,7 +1200,7 @@ def resolve_voice_task_memory_target(workspace, user, draft, current_session=Non .select_related("project") .first() ) - if target_issue: + if is_voice_task_issue_available(target_issue): return target_issue, "target_issue_id", None issue_key_reference = parse_issue_key_reference(target_memory_ref) @@ -1012,26 +1215,25 @@ def resolve_voice_task_memory_target(workspace, user, draft, current_session=Non .select_related("project") .first() ) - if target_issue: + if is_voice_task_issue_available(target_issue): return target_issue, "target_issue_key", None - memory_sessions = ( - VoiceTaskSession.objects.filter( - workspace=workspace, - user=user, - status=VoiceTaskSession.Status.PARSED, - ) - .filter(Q(created_task__isnull=False) | Q(updated_task__isnull=False)) - .select_related("created_task", "created_task__project", "updated_task", "updated_task__project") - .order_by("-updated_at", "-created_at") - ) - if current_session: - memory_sessions = memory_sessions.exclude(id=current_session.id) + projects = list(get_accessible_projects(workspace, user).order_by("name")) + source_project = infer_voice_task_source_project_from_transcript(projects, transcript) + if source_project: + target_issue, memory_session = find_latest_voice_task_issue(memory_sessions, source_project.id) + if target_issue: + return target_issue, "latest_voice_task_source_project", memory_session - for memory_session in memory_sessions[:VOICE_TASK_MEMORY_LIMIT * 3]: - target_issue = get_voice_session_target_issue(memory_session) - if is_voice_task_issue_available(target_issue): - return target_issue, "latest_voice_task", memory_session + current_project_id = normalize_string((client_context or {}).get("current_project_id")) + if current_project_id: + target_issue, memory_session = find_latest_voice_task_issue(memory_sessions, current_project_id) + if target_issue: + return target_issue, "latest_voice_task_current_project", memory_session + + target_issue, memory_session = find_latest_voice_task_issue(memory_sessions) + if target_issue: + return target_issue, "latest_voice_task", memory_session return None, None, None @@ -1066,6 +1268,8 @@ def build_voice_task_resolution(workspace, user, ai_settings, draft, client_cont user=user, draft=draft, current_session=voice_session, + client_context=client_context, + transcript=transcript, ) hydrate_voice_task_due_date( @@ -1493,32 +1697,20 @@ def serialize_workspace_members(workspace): def serialize_recent_voice_memory(workspace, user): - sessions = ( - VoiceTaskSession.objects.filter( - workspace=workspace, - user=user, - status=VoiceTaskSession.Status.PARSED, - ) - .exclude(parsed_json={}) - .select_related("created_task", "created_task__project", "updated_task", "updated_task__project") - .order_by("-updated_at", "-created_at")[:VOICE_TASK_MEMORY_LIMIT] - ) + sessions = get_committed_voice_task_memory_sessions(workspace, user)[:VOICE_TASK_MEMORY_LIMIT] memory = [] for session in sessions: target_issue = get_voice_session_target_issue(session) - target_task = ( - serialize_voice_task_target(target_issue, "recent_voice_memory", session) - if is_voice_task_issue_available(target_issue) - else None - ) + if not is_voice_task_issue_available(target_issue): + continue memory.append( { "voice_session_id": str(session.id), "intent": session.intent, "title": session.parsed_json.get("title"), "project_hint": session.parsed_json.get("project_hint"), - "target_task": target_task, + "target_task": serialize_voice_task_target(target_issue, "recent_voice_memory", session), "created_at": session.created_at.isoformat(), } ) diff --git a/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx b/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx index df9e672..84ba33b 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/board-item.tsx @@ -10,14 +10,9 @@ import { CalendarDays } from "lucide-react"; import { observer } from "mobx-react"; import { EUserPermissions } from "@plane/constants"; import { useTranslation } from "@plane/i18n"; -import { PriorityIcon, StateGroupIcon } from "@plane/propel/icons"; +import { PriorityIcon, StateGroupIcon, getStateGroupColor } from "@plane/propel/icons"; import { TOAST_TYPE, setToast } from "@plane/propel/toast"; -import type { - IState, - TExternalContourBoardDirection, - TExternalContourRequest, - TIssue, -} from "@plane/types"; +import type { IState, TExternalContourBoardDirection, TExternalContourRequest, TIssue } from "@plane/types"; import { Avatar } from "@plane/ui"; import { cn, renderFormattedDate, renderFormattedPayloadDate } from "@plane/utils"; import { DateDropdown } from "@/components/dropdowns/date"; @@ -56,7 +51,7 @@ const buildSourceStateMap = ( state.id, { id: state.id, - color: state.color, + color: getStateGroupColor(state.group, state.color), default: false, description: "", group: state.group, @@ -69,7 +64,10 @@ const buildSourceStateMap = ( ]) ); -const resolveRequestStatus = (issue: TExternalContourRequest["issue"], fallbackStatus: TExternalContourRequest["status"]) => { +const resolveRequestStatus = ( + issue: TExternalContourRequest["issue"], + fallbackStatus: TExternalContourRequest["status"] +) => { const stateGroup = issue.state_detail?.group; if (!stateGroup) return fallbackStatus; return stateGroup === "completed" || stateGroup === "cancelled" ? "closed" : "open"; @@ -83,23 +81,20 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard const { getUserDetails, workspace } = useMember(); const { getProjectRoleByWorkspaceSlugAndProjectId } = useUserPermissions(); const { getStateById, getProjectStateIds } = useProjectState(); - const { - fetchBoard, - upsertBoardItems, - } = useProjectExternalContoursBoard(); - const { - fetchTargetOptions, - getTargetOptionsByProjectId, - updateRequest, - updateRequestIssue, - } = useProjectExternalContours(); + const { fetchBoard, upsertBoardItems } = useProjectExternalContoursBoard(); + const { fetchTargetOptions, getTargetOptionsByProjectId, updateRequest, updateRequestIssue } = + useProjectExternalContours(); const [isUpdating, setIsUpdating] = useState(false); const [isSourceOptionsLoading, setIsSourceOptionsLoading] = useState(false); const issue = request.issue; const selectedInboxIssueId = searchParams.get("inboxIssueId"); const isActive = selectedInboxIssueId === request.id; - const requester = request.requested_by?.display_name || request.requested_by_name || issue.created_by_detail?.display_name || "NODE.DC"; + const requester = + request.requested_by?.display_name || + request.requested_by_name || + issue.created_by_detail?.display_name || + "NODE.DC"; const requesterAvatar = issue.created_by_detail?.avatar_url || ""; const counterpartContourName = direction === "outgoing" @@ -110,8 +105,12 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard ? getProjectRoleByWorkspaceSlugAndProjectId(workspaceSlug, targetProjectId) : undefined; const canEditTargetIssue = - direction === "incoming" && !!targetProjectId && projectRole !== undefined && projectRole !== EUserPermissions.GUEST; - const canEditSourceRequest = direction === "outgoing" && !!request.capabilities?.can_edit_request && !!targetProjectId; + direction === "incoming" && + !!targetProjectId && + projectRole !== undefined && + projectRole !== EUserPermissions.GUEST; + const canEditSourceRequest = + direction === "outgoing" && !!request.capabilities?.can_edit_request && !!targetProjectId; const canEditCard = canEditTargetIssue || canEditSourceRequest; const requestLink = `/${workspaceSlug}/projects/${projectId}/external-contours?inboxIssueId=${request.id}`; const targetOptions = getTargetOptionsByProjectId(targetProjectId); @@ -124,12 +123,11 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard const projectStateIds = issue.project_id ? getProjectStateIds(issue.project_id) : []; const foregroundClasses = isActive ? "text-[rgb(var(--nodedc-on-card-active-rgb))]" : "text-white"; const subtleTextClasses = isActive ? "text-[#2F4721]" : "text-[#B3B3B8]"; - const pillBackgroundClasses = - isActive - ? "bg-black/10 text-[rgb(var(--nodedc-on-card-active-rgb))]" - : "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white"; + const pillBackgroundClasses = isActive + ? "bg-black/10 text-[rgb(var(--nodedc-on-card-active-rgb))]" + : "bg-[rgb(var(--nodedc-card-passive-rgb))] text-white"; const iconBubbleClasses = isActive ? "bg-black text-[rgb(var(--nodedc-card-active-rgb))]" : "bg-[#111214] text-white"; - const statusIconColor = selectedState?.color ?? (isActive ? "rgb(var(--nodedc-on-card-active-rgb))" : "var(--text-color-primary)"); + const statusIconColor = getStateGroupColor(selectedState?.group, selectedState?.color); const dueDateLabel = issue.target_date ? renderFormattedDate(issue.target_date, "d MMM, yyyy") : t("common.none"); if (!issue) return null; @@ -314,13 +312,13 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard -
+
{counterpartContourName || t("common.none")}
-
{issue.name}
+
{issue.name}
@@ -333,7 +331,7 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard disabled={!canEditCard || isUpdating} buttonVariant="transparent-without-text" button={ -
+
} @@ -351,7 +349,7 @@ export const ExternalContoursBoardItem = observer(function ExternalContoursBoard }} buttonVariant="transparent-without-text" button={ -
+
} diff --git a/plane-src/apps/web/ce/components/projects/external-contours/filters/use-external-contours-filters-config.tsx b/plane-src/apps/web/ce/components/projects/external-contours/filters/use-external-contours-filters-config.tsx index 503af75..0208a02 100644 --- a/plane-src/apps/web/ce/components/projects/external-contours/filters/use-external-contours-filters-config.tsx +++ b/plane-src/apps/web/ce/components/projects/external-contours/filters/use-external-contours-filters-config.tsx @@ -17,6 +17,7 @@ import { StateGroupIcon, StatePropertyIcon, UserCirclePropertyIcon, + getStateGroupColor, } from "@plane/propel/icons"; import type { IProject, @@ -57,14 +58,11 @@ const buildCounterpartyProjects = (requests: TExternalContourRequest[], projectI if (!project?.id || project.id === projectId || projectMap.has(project.id)) return; - projectMap.set( - project.id, - { - id: project.id, - name: project.name, - logo_props: project.logo_props, - } as IProject - ); + projectMap.set(project.id, { + id: project.id, + name: project.name, + logo_props: project.logo_props, + } as IProject); }); return sortByName(Array.from(projectMap.values())); @@ -77,21 +75,18 @@ const buildStates = (requests: TExternalContourRequest[]): IState[] => { const state = request.issue.state_detail; if (!state?.id || stateMap.has(state.id)) return; - stateMap.set( - state.id, - { - id: state.id, - color: state.color, - default: false, - description: "", - group: state.group, - name: state.name, - order: index + 1, - project_id: request.issue.project_id || request.target_project?.id || request.source_project?.id || "", - sequence: index + 1, - workspace_id: "", - } as IState - ); + stateMap.set(state.id, { + id: state.id, + color: getStateGroupColor(state.group, state.color), + default: false, + description: "", + group: state.group, + name: state.name, + order: index + 1, + project_id: request.issue.project_id || request.target_project?.id || request.source_project?.id || "", + sequence: index + 1, + workspace_id: "", + } as IState); }); return sortByName(Array.from(stateMap.values())); @@ -103,18 +98,15 @@ const buildLabels = (requests: TExternalContourRequest[]): IIssueLabel[] => { requests.forEach((request) => { request.issue.label_details?.forEach((label) => { if (!label.id || labelMap.has(label.id)) return; - labelMap.set( - label.id, - { - id: label.id, - color: label.color, - name: label.name, - parent: null, - project_id: request.issue.project_id || "", - sort_order: 0, - workspace_id: "", - } as IIssueLabel - ); + labelMap.set(label.id, { + id: label.id, + color: label.color, + name: label.name, + parent: null, + project_id: request.issue.project_id || "", + sort_order: 0, + workspace_id: "", + } as IIssueLabel); }); }); @@ -127,14 +119,11 @@ const buildAssignees = (requests: TExternalContourRequest[]): IUserLite[] => { requests.forEach((request) => { request.issue.assignee_details?.forEach((assignee) => { if (!assignee.id || memberMap.has(assignee.id)) return; - memberMap.set( - assignee.id, - { - id: assignee.id, - avatar_url: assignee.avatar_url, - display_name: assignee.display_name, - } as IUserLite - ); + memberMap.set(assignee.id, { + id: assignee.id, + avatar_url: assignee.avatar_url, + display_name: assignee.display_name, + } as IUserLite); }); }); @@ -151,14 +140,11 @@ const buildRequesters = (requests: TExternalContourRequest[]): IUserLite[] => { if (!requesterId || !requesterName || memberMap.has(requesterId)) return; - memberMap.set( - requesterId, - { - id: requesterId, - avatar_url: request.issue.created_by_detail?.avatar_url, - display_name: requesterName, - } as IUserLite - ); + memberMap.set(requesterId, { + id: requesterId, + avatar_url: request.issue.created_by_detail?.avatar_url, + display_name: requesterName, + } as IUserLite); }); return sortByName(Array.from(memberMap.values())); diff --git a/plane-src/apps/web/core/components/core/modals/bulk-delete-issues-modal-item.tsx b/plane-src/apps/web/core/components/core/modals/bulk-delete-issues-modal-item.tsx index f76cb4c..f5667bd 100644 --- a/plane-src/apps/web/core/components/core/modals/bulk-delete-issues-modal-item.tsx +++ b/plane-src/apps/web/core/components/core/modals/bulk-delete-issues-modal-item.tsx @@ -7,6 +7,7 @@ import { observer } from "mobx-react"; import { Combobox } from "@headlessui/react"; // hooks +import { getStateGroupColor } from "@plane/propel/icons"; import type { ISearchIssueResponse } from "@plane/types"; // plane web hooks import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier"; @@ -19,7 +20,7 @@ interface Props { export const BulkDeleteIssuesModalItem = observer(function BulkDeleteIssuesModalItem(props: Props) { const { issue, canDeleteIssueIds } = props; - const color = issue.state__color; + const color = getStateGroupColor(issue.state__group, issue.state__color); return ( diff --git a/plane-src/apps/web/core/components/gantt-chart/blocks/block-row.tsx b/plane-src/apps/web/core/components/gantt-chart/blocks/block-row.tsx index 4d70c82..8d22262 100644 --- a/plane-src/apps/web/core/components/gantt-chart/blocks/block-row.tsx +++ b/plane-src/apps/web/core/components/gantt-chart/blocks/block-row.tsx @@ -81,7 +81,7 @@ export const BlockRow = observer(function BlockRow(props: Props) { return (
updateActiveBlockId(blockId)} onMouseLeave={() => updateActiveBlockId(null)} style={{ @@ -89,19 +89,18 @@ export const BlockRow = observer(function BlockRow(props: Props) { }} >
{isBlockVisibleOnChart ? isHidden && ( + )} + {VIEWS_LIST.map((chartView: any) => ( -
handleChartView(chartView?.key)} > - {t(chartView?.i18n_title)} -
+ {GANTT_VIEW_SHORT_LABELS[chartView?.key as TGanttViews]} + ))} -
- {showToday && ( - - )} - - +
); }); diff --git a/plane-src/apps/web/core/components/gantt-chart/chart/main-content.tsx b/plane-src/apps/web/core/components/gantt-chart/chart/main-content.tsx index e7de610..14ed210 100644 --- a/plane-src/apps/web/core/components/gantt-chart/chart/main-content.tsx +++ b/plane-src/apps/web/core/components/gantt-chart/chart/main-content.tsx @@ -177,7 +177,7 @@ export const GanttChartMainContent = observer(function GanttChartMainContent(pro // DO NOT REMOVE THE ID id="gantt-container" className={cn( - "vertical-scrollbar horizontal-scrollbar flex scrollbar-lg h-full w-full overflow-auto border-t-[0.5px] border-subtle", + "nodedc-project-gantt-scroll vertical-scrollbar horizontal-scrollbar flex scrollbar-lg h-full w-full overflow-auto", { "mb-8": bottomSpacing, } @@ -199,11 +199,11 @@ export const GanttChartMainContent = observer(function GanttChartMainContent(pro showAllBlocks={showAllBlocks} isEpic={isEpic} /> -
+
{currentViewData && (
@@ -192,6 +192,7 @@ export const ChartViewRoot = observer(function ChartViewRoot(props: ChartViewRoo handleChartView={(key) => updateCurrentViewRenderPayload(null, key)} handleToday={handleToday} loaderTitle={loaderTitle} + title={title} showToday={showToday} /> +
{currentViewData && ( -
+
{/** Header Div */}
(
{monthBlock?.title} {monthBlock.today && ( - + Current )} @@ -70,9 +74,9 @@ export const MonthChartView = observer(function MonthChartView(_props: any) {
{weekBlock.startDate.getDate()}-{weekBlock.endDate.getDate()} @@ -96,8 +101,8 @@ export const MonthChartView = observer(function MonthChartView(_props: any) { {weeks?.map((weekBlock) => (
diff --git a/plane-src/apps/web/core/components/gantt-chart/chart/views/quarter.tsx b/plane-src/apps/web/core/components/gantt-chart/chart/views/quarter.tsx index 9b13567..5efd24d 100644 --- a/plane-src/apps/web/core/components/gantt-chart/chart/views/quarter.tsx +++ b/plane-src/apps/web/core/components/gantt-chart/chart/views/quarter.tsx @@ -21,16 +21,16 @@ export const QuarterChartView = observer(function QuarterChartView(_props: any) const quarterBlocks: IQuarterMonthBlock[] = groupMonthsToQuarters(monthBlocks); return ( -
+
{currentViewData && quarterBlocks?.map((quarterBlock, rootIndex) => (
{/** Header Div */}
{quarterBlock?.title} {quarterBlock.today && ( - + Current )}
-
+
{quarterBlock.shortTitle}
@@ -60,9 +64,9 @@ export const QuarterChartView = observer(function QuarterChartView(_props: any)
{monthBlock.monthData.shortTitle} @@ -85,8 +90,8 @@ export const QuarterChartView = observer(function QuarterChartView(_props: any) {quarterBlock?.children?.map((monthBlock, index) => (
diff --git a/plane-src/apps/web/core/components/gantt-chart/chart/views/week.tsx b/plane-src/apps/web/core/components/gantt-chart/chart/views/week.tsx index 2f96eb1..ae6bf46 100644 --- a/plane-src/apps/web/core/components/gantt-chart/chart/views/week.tsx +++ b/plane-src/apps/web/core/components/gantt-chart/chart/views/week.tsx @@ -18,16 +18,16 @@ export const WeekChartView = observer(function WeekChartView(_props: any) { const weekBlocks: IWeekBlock[] = renderView; return ( -
+
{currentViewData && weekBlocks?.map((block, rootIndex) => (
{/** Header Div */}
{block?.title}
-
+
{block?.weekData?.title}
@@ -52,9 +52,9 @@ export const WeekChartView = observer(function WeekChartView(_props: any) {
{weekDay.date.getDate()} @@ -74,17 +75,17 @@ export const WeekChartView = observer(function WeekChartView(_props: any) {
{/** Day Columns */} -
+
{block?.children?.map((weekDay, index) => (
{["sat", "sun"].includes(weekDay?.dayData?.shortTitle) && ( -
+
)}
))} diff --git a/plane-src/apps/web/core/components/gantt-chart/constants.ts b/plane-src/apps/web/core/components/gantt-chart/constants.ts index 04ab7d2..402c3ea 100644 --- a/plane-src/apps/web/core/components/gantt-chart/constants.ts +++ b/plane-src/apps/web/core/components/gantt-chart/constants.ts @@ -4,11 +4,11 @@ * See the LICENSE file for details. */ -export const BLOCK_HEIGHT = 44; +export const BLOCK_HEIGHT = 46; -export const HEADER_HEIGHT = 48; +export const HEADER_HEIGHT = 56; -export const GANTT_BREADCRUMBS_HEIGHT = 40; +export const GANTT_BREADCRUMBS_HEIGHT = 60; export const SIDEBAR_WIDTH = 360; diff --git a/plane-src/apps/web/core/components/gantt-chart/helpers/blockResizables/left-resizable.tsx b/plane-src/apps/web/core/components/gantt-chart/helpers/blockResizables/left-resizable.tsx index 11ffb03..0dda8c0 100644 --- a/plane-src/apps/web/core/components/gantt-chart/helpers/blockResizables/left-resizable.tsx +++ b/plane-src/apps/web/core/components/gantt-chart/helpers/blockResizables/left-resizable.tsx @@ -39,7 +39,7 @@ export const LeftResizable = observer(function LeftResizable(props: LeftResizabl <> {(isHovering || isLeftResizing) && dateString && (
-
{dateString}
+
{dateString}
)}
{(isHovering || isRightResizing) && dateString && (
-
{dateString}
+
{dateString}
)}
+
{/* left resize drag handle */} {(typeof enableDependency === "function" ? enableDependency(block.id) : enableDependency) && ( @@ -55,7 +55,7 @@ export const ChartDraggable = observer(function ChartDraggable(props: Props) { position={block.position} />
enableBlockMove && handleBlockDrag(e, "move")} diff --git a/plane-src/apps/web/core/components/gantt-chart/sidebar/issues/block.tsx b/plane-src/apps/web/core/components/gantt-chart/sidebar/issues/block.tsx index e7b4f27..07901de 100644 --- a/plane-src/apps/web/core/components/gantt-chart/sidebar/issues/block.tsx +++ b/plane-src/apps/web/core/components/gantt-chart/sidebar/issues/block.tsx @@ -44,23 +44,19 @@ export const IssuesSidebarBlock = observer(function IssuesSidebarBlock(props: Pr return (
updateActiveBlockId(block.id)} onMouseLeave={() => updateActiveBlockId(null)} > +
{blockIds ? ( <> {blockIds.map((blockId, index) => { @@ -117,7 +117,7 @@ export const IssueGanttSidebar = observer(function IssueGanttSidebar(props: Prop })} {canLoadMoreBlocks && (
-
+
)} diff --git a/plane-src/apps/web/core/components/gantt-chart/sidebar/root.tsx b/plane-src/apps/web/core/components/gantt-chart/sidebar/root.tsx index e8680fd..f8e1669 100644 --- a/plane-src/apps/web/core/components/gantt-chart/sidebar/root.tsx +++ b/plane-src/apps/web/core/components/gantt-chart/sidebar/root.tsx @@ -56,14 +56,14 @@ export const GanttChartSidebar = observer(function GanttChartSidebar(props: Prop {t("common.duration")} - + {sidebarToRender && sidebarToRender({ title, diff --git a/plane-src/apps/web/core/components/home/home-recent-issue-decks.tsx b/plane-src/apps/web/core/components/home/home-recent-issue-decks.tsx index f0276bd..8ad5f1c 100644 --- a/plane-src/apps/web/core/components/home/home-recent-issue-decks.tsx +++ b/plane-src/apps/web/core/components/home/home-recent-issue-decks.tsx @@ -34,11 +34,13 @@ const EXTERNAL_DECK_LIMIT = 10; const INTERNAL_CURSOR = `${INTERNAL_DECK_LIMIT}:0:0`; type HomeRecentIssueDecksProps = { + compact?: boolean; project?: THomeProjectData; workspaceSlug: string; }; type DeckSectionProps = { + compact?: boolean; count: number; description: string; emptyDescription: string; @@ -49,6 +51,7 @@ type DeckSectionProps = { }; type InternalIssueCardProps = { + compact?: boolean; isActive: boolean; issue: TIssue; onSelect: () => void; @@ -56,6 +59,7 @@ type InternalIssueCardProps = { }; type ExternalIssueCardProps = { + compact?: boolean; isActive: boolean; onSelect: () => void; project: THomeProjectData; @@ -76,14 +80,14 @@ const sortByRecentCreatedDate = < }; const DeckSection = (props: DeckSectionProps) => { - const { count, description, emptyDescription, emptyTitle, isLoading, items, title } = props; + const { compact = false, count, description, emptyDescription, emptyTitle, isLoading, items, title } = props; return (
{title}
-
{description}
+ {!compact &&
{description}
}
@@ -91,16 +95,24 @@ const DeckSection = (props: DeckSectionProps) => {
-
-
+
+
{isLoading ? Array.from({ length: 4 }, (_, index) => (
0, + "nodedc-home-task-card-compact": compact, })} - style={{ zIndex: 5 - index }} /> )) : items} @@ -118,7 +130,7 @@ const DeckSection = (props: DeckSectionProps) => { }; const HomeInternalContourDeckCard = observer(function HomeInternalContourDeckCard(props: InternalIssueCardProps) { - const { isActive, issue, onSelect, project } = props; + const { compact = false, isActive, issue, onSelect, project } = props; const { t } = useTranslation(); const { getProjectById } = useProject(); const { getUserDetails } = useMember(); @@ -183,7 +195,7 @@ const HomeInternalContourDeckCard = observer(function HomeInternalContourDeckCar const footer = ( <> -
+
{(issue.assignee_ids?.length ?? 0) > 0 ? ( ) : ( @@ -191,7 +203,12 @@ const HomeInternalContourDeckCard = observer(function HomeInternalContourDeckCar )}
-
+
{dueDateLabel}
@@ -199,14 +216,24 @@ const HomeInternalContourDeckCard = observer(function HomeInternalContourDeckCar ); return ( -