From 5434a7e60108035314f2eaf9c5d36c008b02ae48 Mon Sep 17 00:00:00 2001 From: Konstantin Dyachenko Date: Mon, 30 Mar 2026 10:25:01 +0700 Subject: [PATCH] [Final] TaskBoard --- Assets/Editor/Tasks/TaskBoardModels.cs | 15 +- Assets/Editor/Tasks/TaskBoardOwners.asset | 19 + .../Editor/Tasks/TaskBoardOwners.asset.meta | 8 + Assets/Editor/Tasks/TaskBoardOwnersConfig.cs | 10 + .../Tasks/TaskBoardOwnersConfig.cs.meta | 2 + Assets/Editor/Tasks/TaskBoardService.cs | 794 ++++++++++++++++-- Assets/Editor/Tasks/TaskBoardWindow.cs | 655 ++++++++++++--- docs/tasks/Index.md | 33 +- docs/tasks/_template.md | 13 +- 9 files changed, 1346 insertions(+), 203 deletions(-) create mode 100644 Assets/Editor/Tasks/TaskBoardOwners.asset create mode 100644 Assets/Editor/Tasks/TaskBoardOwners.asset.meta create mode 100644 Assets/Editor/Tasks/TaskBoardOwnersConfig.cs create mode 100644 Assets/Editor/Tasks/TaskBoardOwnersConfig.cs.meta diff --git a/Assets/Editor/Tasks/TaskBoardModels.cs b/Assets/Editor/Tasks/TaskBoardModels.cs index 7e884095..f6523474 100644 --- a/Assets/Editor/Tasks/TaskBoardModels.cs +++ b/Assets/Editor/Tasks/TaskBoardModels.cs @@ -5,7 +5,13 @@ namespace Project.Tasks.Editor { internal static class TaskBoardConstants { - public static readonly string[] Statuses = { "proposal", "ready", "in_progress", "blocked", "done" }; + public const int MinutesPerHour = 60; + public const int HoursPerDay = 8; + public const int MinutesPerDay = HoursPerDay * MinutesPerHour; + public const int WorkDaysPerWeek = 5; + public const int MinutesPerWeek = WorkDaysPerWeek * MinutesPerDay; + + public static readonly string[] Statuses = { "BackLog", "ToDo", "InProgress", "Review", "Done" }; public static readonly string[] Priorities = { "Lowest", "Low", "Medium", "High", "Highest" }; } @@ -23,7 +29,9 @@ namespace Project.Tasks.Editor public string ExecutionTime; public string RelativeFilePath; public string AbsoluteFilePath; - public string Summary; + public string IndexSummary; + public string TaskSummary; + public int EstimatedMinutes = -1; public string Header; public string Why; public string ExpectedOutcome; @@ -35,6 +43,7 @@ namespace Project.Tasks.Editor public string DecisionLog; public string HandoffNotes; public bool FileExists; + public bool DetailsLoaded; public int IndexLineNumber = -1; public readonly List ValidationMessages = new List(); } @@ -45,7 +54,9 @@ namespace Project.Tasks.Editor public string ProjectRoot; public string TasksDirectory; public string IndexPath; + public string OwnersConfigPath; public readonly List Tasks = new List(); public readonly List Warnings = new List(); + public readonly List OwnerPresets = new List(); } } diff --git a/Assets/Editor/Tasks/TaskBoardOwners.asset b/Assets/Editor/Tasks/TaskBoardOwners.asset new file mode 100644 index 00000000..676be187 --- /dev/null +++ b/Assets/Editor/Tasks/TaskBoardOwners.asset @@ -0,0 +1,19 @@ +%YAML 1.1 +%TAG !u! tag:unity3d.com,2011: +--- !u!114 &11400000 +MonoBehaviour: + m_ObjectHideFlags: 0 + m_CorrespondingSourceObject: {fileID: 0} + m_PrefabInstance: {fileID: 0} + m_PrefabAsset: {fileID: 0} + m_GameObject: {fileID: 0} + m_Enabled: 1 + m_EditorHideFlags: 0 + m_Script: {fileID: 11500000, guid: acfa62607cc78b8499e371559154647f, type: 3} + m_Name: TaskBoardOwners + m_EditorClassIdentifier: Assembly-CSharp-Editor::Project.Tasks.Editor.TaskBoardOwnersConfig + owners: + - unassigned + - pretty_kotik + - gitenax + - abysscion diff --git a/Assets/Editor/Tasks/TaskBoardOwners.asset.meta b/Assets/Editor/Tasks/TaskBoardOwners.asset.meta new file mode 100644 index 00000000..e36a49f7 --- /dev/null +++ b/Assets/Editor/Tasks/TaskBoardOwners.asset.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 84c93775036fa9242b5e5e4397f96d04 +NativeFormatImporter: + externalObjects: {} + mainObjectFileID: 11400000 + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/Tasks/TaskBoardOwnersConfig.cs b/Assets/Editor/Tasks/TaskBoardOwnersConfig.cs new file mode 100644 index 00000000..5c90f337 --- /dev/null +++ b/Assets/Editor/Tasks/TaskBoardOwnersConfig.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace Project.Tasks.Editor +{ + internal sealed class TaskBoardOwnersConfig : ScriptableObject + { + public List owners = new List { "unassigned" }; + } +} diff --git a/Assets/Editor/Tasks/TaskBoardOwnersConfig.cs.meta b/Assets/Editor/Tasks/TaskBoardOwnersConfig.cs.meta new file mode 100644 index 00000000..6f5cf584 --- /dev/null +++ b/Assets/Editor/Tasks/TaskBoardOwnersConfig.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: acfa62607cc78b8499e371559154647f \ No newline at end of file diff --git a/Assets/Editor/Tasks/TaskBoardService.cs b/Assets/Editor/Tasks/TaskBoardService.cs index 7ca9fc80..23163002 100644 --- a/Assets/Editor/Tasks/TaskBoardService.cs +++ b/Assets/Editor/Tasks/TaskBoardService.cs @@ -6,15 +6,30 @@ using System.IO; using System.Linq; using System.Text; using System.Text.RegularExpressions; +using UnityEditor; using UnityEngine; namespace Project.Tasks.Editor { internal static class TaskBoardService { + private static readonly string[] EditableSectionOrder = + { + "Why", + "Expected Outcome", + "Current Context", + "Acceptance Criteria", + "Verification", + "Risks / Open Questions", + "Human Decisions Needed", + "Decision Log", + "Handoff Notes", + }; + private static readonly Regex HeadingRegex = new Regex(@"^##\s+(.+)$", RegexOptions.Compiled); private static readonly Regex H1Regex = new Regex(@"^#\s+(.+)$", RegexOptions.Compiled); private static readonly Regex JiraTimeRegex = new Regex(@"^(?:(\d+)w)?(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private static readonly Regex ExecutionTokenRegex = new Regex(@"(?i)(\d+)\s*([wdhm])", RegexOptions.Compiled); public static string NormalizePriority(string value) { @@ -45,6 +60,39 @@ namespace Project.Tasks.Editor } } + public static string NormalizeStatus(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return string.Empty; + } + + switch (value.Trim()) + { + case "proposal": + case "backlog": + case "BackLog": + return "BackLog"; + case "ready": + case "todo": + case "ToDo": + return "ToDo"; + case "in_progress": + case "inprogress": + case "InProgress": + return "InProgress"; + case "blocked": + case "review": + case "Review": + return "Review"; + case "done": + case "Done": + return "Done"; + default: + return value.Trim(); + } + } + public static int GetPrioritySortOrder(string value) { switch (NormalizePriority(value)) @@ -70,6 +118,8 @@ namespace Project.Tasks.Editor data.ProjectRoot = GetProjectRoot(); data.TasksDirectory = NormalizePath(Path.Combine(data.ProjectRoot, "docs", "tasks")); data.IndexPath = NormalizePath(Path.Combine(data.TasksDirectory, "Index.md")); + data.OwnersConfigPath = "Assets/Editor/Tasks/TaskBoardOwners.asset"; + LoadOwnerPresets(data); if (!Directory.Exists(data.TasksDirectory)) { @@ -86,43 +136,44 @@ namespace Project.Tasks.Editor string[] lines = File.ReadAllLines(data.IndexPath, Encoding.UTF8); ParseIndex(data, lines); - string[] taskFiles = Directory.GetFiles(data.TasksDirectory, "TASK-*.md", SearchOption.TopDirectoryOnly); - var indexedIds = new HashSet(data.Tasks.Select(task => task.Id), StringComparer.OrdinalIgnoreCase); - - foreach (string taskPath in taskFiles) - { - string taskId = Path.GetFileNameWithoutExtension(taskPath); - int dashIndex = taskId.IndexOf('-'); - if (dashIndex >= 0) - { - int secondDash = taskId.IndexOf('-', dashIndex + 1); - if (secondDash > 0) - { - taskId = taskId.Substring(0, secondDash); - } - } - - if (!indexedIds.Contains(taskId)) - { - data.Warnings.Add("Task-файл без записи в реестре: " + NormalizePath(GetRelativePath(data.ProjectRoot, taskPath))); - } - } - data.Tasks.Sort(CompareTaskIds); return data; } + public static void LoadTaskDetails(TaskRecord task) + { + if (task == null || task.DetailsLoaded) + { + return; + } + + task.DetailsLoaded = true; + + if (!task.FileExists || string.IsNullOrEmpty(task.AbsoluteFilePath) || !File.Exists(task.AbsoluteFilePath)) + { + if (string.IsNullOrEmpty(task.Title)) + { + task.Title = DeriveTitleFromPath(task.RelativeFilePath, task.Id); + } + return; + } + + PopulateTaskFromFile(task); + ValidateTask(task); + } + public static bool Save(TaskBoardData data, TaskRecord updatedTask, out string error) { error = null; try { + CanonicalizeTask(updatedTask); SaveIndexRow(data.IndexPath, updatedTask); if (updatedTask.FileExists && File.Exists(updatedTask.AbsoluteFilePath)) { - SaveTaskFrontMatter(updatedTask.AbsoluteFilePath, updatedTask); + SaveTaskFile(updatedTask.AbsoluteFilePath, updatedTask); } return true; @@ -134,6 +185,295 @@ namespace Project.Tasks.Editor } } + public static bool DeleteTask(TaskBoardData data, TaskRecord task, out string error) + { + error = null; + + try + { + if (data == null) + { + throw new InvalidOperationException("Данные task board не загружены."); + } + + if (task == null) + { + throw new InvalidOperationException("Задача не выбрана."); + } + + DeleteIndexRow(data.IndexPath, task); + + if (task.FileExists && !string.IsNullOrWhiteSpace(task.AbsoluteFilePath) && File.Exists(task.AbsoluteFilePath)) + { + File.Delete(task.AbsoluteFilePath); + } + + return true; + } + catch (Exception exception) + { + error = exception.Message; + return false; + } + } + + public static bool TryParseExecutionTimeToMinutes(string value, out int minutes) + { + minutes = 0; + string normalized; + if (!TryNormalizeExecutionTime(value, out normalized, out minutes)) + { + return false; + } + + return true; + } + + public static string NormalizeExecutionTime(string value) + { + string normalized; + int minutes; + return TryNormalizeExecutionTime(value, out normalized, out minutes) ? normalized : (string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim()); + } + + public static bool TryNormalizeExecutionTime(string value, out string normalized, out int totalMinutes) + { + normalized = string.Empty; + totalMinutes = 0; + + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + string input = value.Trim(); + MatchCollection matches = ExecutionTokenRegex.Matches(input); + if (matches.Count == 0) + { + return false; + } + + string leftover = ExecutionTokenRegex.Replace(input, string.Empty); + leftover = Regex.Replace(leftover, @"\s+", string.Empty); + if (!string.IsNullOrEmpty(leftover)) + { + return false; + } + + int weeks = 0; + int days = 0; + int hours = 0; + int minutes = 0; + + foreach (Match match in matches) + { + int amount = ParseNumber(match.Groups[1].Value); + string unit = match.Groups[2].Value.ToLowerInvariant(); + switch (unit) + { + case "w": + weeks += amount; + break; + case "d": + days += amount; + break; + case "h": + hours += amount; + break; + case "m": + minutes += amount; + break; + default: + return false; + } + } + + totalMinutes = (weeks * TaskBoardConstants.MinutesPerWeek) + + (days * TaskBoardConstants.MinutesPerDay) + + (hours * TaskBoardConstants.MinutesPerHour) + + minutes; + + if (totalMinutes <= 0 || totalMinutes % 30 != 0) + { + return false; + } + + normalized = BuildCanonicalExecutionTime(totalMinutes); + return true; + } + + public static string FormatMinutesForDisplay(int totalMinutes) + { + if (totalMinutes < 0) + { + return "-"; + } + + int remaining = totalMinutes; + int days = remaining / TaskBoardConstants.MinutesPerDay; + remaining %= TaskBoardConstants.MinutesPerDay; + int hours = remaining / TaskBoardConstants.MinutesPerHour; + remaining %= TaskBoardConstants.MinutesPerHour; + int minutes = remaining; + + var parts = new List(); + if (days > 0) + { + parts.Add(days + "d"); + } + + if (hours > 0) + { + parts.Add(hours + "h"); + } + + if (minutes > 0 || parts.Count == 0) + { + parts.Add(minutes + "m"); + } + + return string.Join(" ", parts) + " (" + totalMinutes + "m)"; + } + + public static string FormatExecutionTimeForDisplay(string value) + { + string normalized; + int totalMinutes; + if (!TryNormalizeExecutionTime(value, out normalized, out totalMinutes)) + { + return string.IsNullOrWhiteSpace(value) ? "-" : value; + } + + Match match = JiraTimeRegex.Match(normalized); + var parts = new List(); + + if (match.Groups[1].Success) + { + parts.Add(match.Groups[1].Value + "w"); + } + + if (match.Groups[2].Success) + { + parts.Add(match.Groups[2].Value + "d"); + } + + if (match.Groups[3].Success) + { + parts.Add(match.Groups[3].Value + "h"); + } + + if (match.Groups[4].Success) + { + parts.Add(match.Groups[4].Value + "m"); + } + + if (parts.Count == 0) + { + parts.Add("0m"); + } + + return string.Join(" ", parts) + " (" + totalMinutes + "m)"; + } + + public static int SumEstimatedMinutes(IEnumerable tasks) + { + int total = 0; + foreach (TaskRecord task in tasks) + { + if (task != null && task.EstimatedMinutes >= 0) + { + total += task.EstimatedMinutes; + } + } + + return total; + } + + public static string[] BuildOwnerOptions(TaskBoardData data, IEnumerable tasks) + { + var values = new HashSet(StringComparer.OrdinalIgnoreCase) { "unassigned" }; + + if (data != null) + { + foreach (string owner in data.OwnerPresets) + { + if (!string.IsNullOrWhiteSpace(owner)) + { + values.Add(owner.Trim()); + } + } + } + + if (tasks != null) + { + foreach (TaskRecord task in tasks) + { + if (task != null && !string.IsNullOrWhiteSpace(task.Owner)) + { + values.Add(task.Owner.Trim()); + } + } + } + + return values.OrderBy(value => value, StringComparer.OrdinalIgnoreCase).ToArray(); + } + + public static bool CanonicalizeTask(TaskRecord task) + { + if (task == null) + { + return false; + } + + bool changed = false; + + string normalizedStatus = NormalizeStatus(task.Status); + if (!string.Equals(task.Status, normalizedStatus, StringComparison.Ordinal)) + { + task.Status = normalizedStatus; + changed = true; + } + + string normalizedPriority = NormalizePriority(task.Priority); + if (!string.Equals(task.Priority, normalizedPriority, StringComparison.Ordinal)) + { + task.Priority = normalizedPriority; + changed = true; + } + + string normalizedOwner = string.IsNullOrWhiteSpace(task.Owner) ? "unassigned" : task.Owner.Trim(); + if (!string.Equals(task.Owner, normalizedOwner, StringComparison.Ordinal)) + { + task.Owner = normalizedOwner; + changed = true; + } + + if (string.IsNullOrWhiteSpace(task.Area)) + { + task.Area = "-"; + changed = true; + } + + string normalizedExecutionTime; + int totalMinutes; + if (TryNormalizeExecutionTime(task.ExecutionTime, out normalizedExecutionTime, out totalMinutes)) + { + if (!string.Equals(task.ExecutionTime, normalizedExecutionTime, StringComparison.Ordinal)) + { + task.ExecutionTime = normalizedExecutionTime; + changed = true; + } + + task.EstimatedMinutes = totalMinutes; + } + else + { + task.EstimatedMinutes = -1; + } + + return changed; + } + public static bool CreateTaskFileFromTemplate(TaskBoardData data, TaskRecord task, out string error) { error = null; @@ -190,6 +530,7 @@ namespace Project.Tasks.Editor string content = template .Replace("id: TASK-XXXX", "id: " + task.Id) .Replace("title: Короткий заголовок", "title: " + title) + .Replace("summary: Короткое описание задачи", "summary: " + EscapeFrontMatterValue(string.IsNullOrWhiteSpace(task.IndexSummary) ? title : task.IndexSummary)) .Replace("priority: Medium", "priority: " + priority) .Replace("area: docs", "area: " + area) .Replace("owner: unassigned", "owner: " + owner) @@ -207,6 +548,7 @@ namespace Project.Tasks.Editor File.WriteAllText(absolutePath, content, new UTF8Encoding(false)); task.AbsoluteFilePath = absolutePath; task.FileExists = true; + task.DetailsLoaded = false; return true; } catch (Exception exception) @@ -234,47 +576,9 @@ namespace Project.Tasks.Editor public static bool IsValidExecutionTime(string value) { - if (string.IsNullOrWhiteSpace(value)) - { - return false; - } - - Match match = JiraTimeRegex.Match(value.Trim()); - if (!match.Success) - { - return false; - } - - bool hasAnyGroup = false; - for (int i = 1; i < match.Groups.Count; i++) - { - if (match.Groups[i].Success) - { - hasAnyGroup = true; - break; - } - } - - if (!hasAnyGroup) - { - return false; - } - - if (match.Groups[4].Success) - { - int minutes; - if (!int.TryParse(match.Groups[4].Value, NumberStyles.None, CultureInfo.InvariantCulture, out minutes)) - { - return false; - } - - if (minutes % 30 != 0) - { - return false; - } - } - - return true; + string normalized; + int totalMinutes; + return TryNormalizeExecutionTime(value, out normalized, out totalMinutes); } public static string GetProjectRoot() @@ -368,15 +672,18 @@ namespace Project.Tasks.Editor continue; } + bool hasOwnerColumn = columns.Length >= 8; + var task = new TaskRecord { Id = columns[0], - Status = columns[1], + Status = NormalizeStatus(columns[1]), Priority = NormalizePriority(columns[2]), Area = columns[3], - ExecutionTime = columns[4], - RelativeFilePath = StripTicks(columns[5]), - Summary = columns[6], + Owner = hasOwnerColumn ? columns[4] : "unassigned", + ExecutionTime = NormalizeExecutionTime(hasOwnerColumn ? columns[5] : columns[4]), + RelativeFilePath = StripTicks(hasOwnerColumn ? columns[6] : columns[5]), + IndexSummary = hasOwnerColumn ? columns[7] : columns[6], IndexLineNumber = i, }; @@ -384,6 +691,7 @@ namespace Project.Tasks.Editor { task.AbsoluteFilePath = NormalizePath(Path.Combine(data.ProjectRoot, task.RelativeFilePath)); task.FileExists = File.Exists(task.AbsoluteFilePath); + task.Title = DeriveTitleFromPath(task.RelativeFilePath, task.Id); } if (!task.FileExists) @@ -391,7 +699,8 @@ namespace Project.Tasks.Editor task.ValidationMessages.Add("Task-файл не найден по пути из реестра."); } - PopulateTaskFromFile(task); + int estimatedMinutes; + task.EstimatedMinutes = TryParseExecutionTimeToMinutes(task.ExecutionTime, out estimatedMinutes) ? estimatedMinutes : -1; ValidateTask(task); data.Tasks.Add(task); } @@ -399,11 +708,23 @@ namespace Project.Tasks.Editor private static void PopulateTaskFromFile(TaskRecord task) { + task.TaskSummary = string.Empty; + task.Header = string.Empty; + task.Why = string.Empty; + task.ExpectedOutcome = string.Empty; + task.CurrentContext = string.Empty; + task.AcceptanceCriteria = string.Empty; + task.Verification = string.Empty; + task.Risks = string.Empty; + task.HumanDecisions = string.Empty; + task.DecisionLog = string.Empty; + task.HandoffNotes = string.Empty; + if (!task.FileExists || string.IsNullOrEmpty(task.AbsoluteFilePath)) { if (string.IsNullOrEmpty(task.Title)) { - task.Title = task.Id; + task.Title = DeriveTitleFromPath(task.RelativeFilePath, task.Id); } return; } @@ -504,6 +825,9 @@ namespace Project.Tasks.Editor case "title": task.Title = value; break; + case "summary": + task.TaskSummary = value; + break; case "priority": string normalizedPriority = NormalizePriority(value); if (!string.IsNullOrEmpty(normalizedPriority) && !string.Equals(task.Priority, normalizedPriority, StringComparison.OrdinalIgnoreCase)) @@ -518,7 +842,10 @@ namespace Project.Tasks.Editor } break; case "owner": - task.Owner = value; + if (!string.IsNullOrWhiteSpace(value) && !string.Equals(task.Owner, value, StringComparison.OrdinalIgnoreCase)) + { + task.ValidationMessages.Add("Owner в task-файле не совпадает с реестром."); + } break; case "created": task.Created = value; @@ -527,7 +854,8 @@ namespace Project.Tasks.Editor task.Updated = value; break; case "execution_time": - if (!string.IsNullOrEmpty(value) && !string.Equals(task.ExecutionTime, value, StringComparison.OrdinalIgnoreCase)) + string normalizedExecutionTime = NormalizeExecutionTime(value); + if (!string.IsNullOrEmpty(normalizedExecutionTime) && !string.Equals(task.ExecutionTime, normalizedExecutionTime, StringComparison.OrdinalIgnoreCase)) { task.ValidationMessages.Add("execution_time в task-файле не совпадает с реестром."); } @@ -537,6 +865,7 @@ namespace Project.Tasks.Editor private static void ValidateTask(TaskRecord task) { + task.Status = NormalizeStatus(task.Status); if (Array.IndexOf(TaskBoardConstants.Statuses, task.Status) < 0) { task.ValidationMessages.Add("Неизвестный статус в Index.md: " + task.Status); @@ -548,10 +877,16 @@ namespace Project.Tasks.Editor task.ValidationMessages.Add("Неизвестный приоритет в Index.md: " + task.Priority); } + task.ExecutionTime = NormalizeExecutionTime(task.ExecutionTime); if (!IsValidExecutionTime(task.ExecutionTime)) { task.ValidationMessages.Add("execution_time не соответствует Jira-формату или не кратен 30 минутам."); } + else + { + int estimatedMinutes; + task.EstimatedMinutes = TryParseExecutionTimeToMinutes(task.ExecutionTime, out estimatedMinutes) ? estimatedMinutes : -1; + } if (string.IsNullOrEmpty(task.RelativeFilePath)) { @@ -571,7 +906,7 @@ namespace Project.Tasks.Editor File.WriteAllText(indexPath, string.Join("\n", lines), new UTF8Encoding(false)); } - private static void SaveTaskFrontMatter(string taskPath, TaskRecord task) + private static void SaveTaskFile(string taskPath, TaskRecord task) { var lines = File.ReadAllLines(taskPath, Encoding.UTF8).ToList(); string today = DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); @@ -583,6 +918,7 @@ namespace Project.Tasks.Editor "---", "id: " + task.Id, "title: " + (string.IsNullOrEmpty(task.Title) ? task.Id : task.Title), + "summary: " + EscapeFrontMatterValue(string.IsNullOrWhiteSpace(task.TaskSummary) ? task.IndexSummary : task.TaskSummary), "priority: " + task.Priority, "area: " + task.Area, "owner: " + (string.IsNullOrEmpty(task.Owner) ? "unassigned" : task.Owner), @@ -614,6 +950,8 @@ namespace Project.Tasks.Editor throw new InvalidOperationException("Не удалось прочитать front matter task-файла."); } + UpsertFrontMatterField(lines, ref frontMatterEnd, "title", string.IsNullOrEmpty(task.Title) ? task.Id : task.Title); + UpsertFrontMatterField(lines, ref frontMatterEnd, "summary", EscapeFrontMatterValue(string.IsNullOrWhiteSpace(task.TaskSummary) ? task.IndexSummary : task.TaskSummary)); UpsertFrontMatterField(lines, ref frontMatterEnd, "priority", task.Priority); UpsertFrontMatterField(lines, ref frontMatterEnd, "area", task.Area); UpsertFrontMatterField(lines, ref frontMatterEnd, "owner", string.IsNullOrEmpty(task.Owner) ? "unassigned" : task.Owner); @@ -621,7 +959,69 @@ namespace Project.Tasks.Editor UpsertFrontMatterField(lines, ref frontMatterEnd, "updated", today); UpsertFrontMatterField(lines, ref frontMatterEnd, "execution_time", task.ExecutionTime); - File.WriteAllText(taskPath, string.Join("\n", lines), new UTF8Encoding(false)); + int bodyStart = frontMatterEnd + 1; + while (bodyStart < lines.Count && string.IsNullOrWhiteSpace(lines[bodyStart])) + { + bodyStart++; + } + + var sections = ParseBodySections(lines, bodyStart, task); + sections.ValuesByHeading["Why"] = task.Why ?? string.Empty; + sections.ValuesByHeading["Expected Outcome"] = task.ExpectedOutcome ?? string.Empty; + sections.ValuesByHeading["Current Context"] = task.CurrentContext ?? string.Empty; + sections.ValuesByHeading["Acceptance Criteria"] = task.AcceptanceCriteria ?? string.Empty; + sections.ValuesByHeading["Verification"] = task.Verification ?? string.Empty; + sections.ValuesByHeading["Risks / Open Questions"] = task.Risks ?? string.Empty; + sections.ValuesByHeading["Human Decisions Needed"] = task.HumanDecisions ?? string.Empty; + sections.ValuesByHeading["Decision Log"] = task.DecisionLog ?? string.Empty; + sections.ValuesByHeading["Handoff Notes"] = task.HandoffNotes ?? string.Empty; + + string header = string.IsNullOrWhiteSpace(task.Title) ? task.Id : task.Id + " - " + task.Title; + var rebuilt = new List(); + rebuilt.AddRange(lines.Take(frontMatterEnd + 1)); + rebuilt.Add(string.Empty); + rebuilt.Add("# " + header); + rebuilt.Add(string.Empty); + + foreach (string heading in sections.OrderedHeadings) + { + if (!sections.ValuesByHeading.ContainsKey(heading)) + { + continue; + } + + rebuilt.Add("## " + heading); + rebuilt.Add(string.Empty); + AddSectionContent(rebuilt, sections.ValuesByHeading[heading]); + rebuilt.Add(string.Empty); + } + + foreach (string heading in EditableSectionOrder) + { + if (sections.OrderedHeadings.Contains(heading)) + { + continue; + } + + string content; + if (!sections.ValuesByHeading.TryGetValue(heading, out content) || string.IsNullOrWhiteSpace(content)) + { + continue; + } + + rebuilt.Add("## " + heading); + rebuilt.Add(string.Empty); + AddSectionContent(rebuilt, content); + rebuilt.Add(string.Empty); + } + + while (rebuilt.Count > 0 && string.IsNullOrWhiteSpace(rebuilt[rebuilt.Count - 1])) + { + rebuilt.RemoveAt(rebuilt.Count - 1); + } + + File.WriteAllText(taskPath, string.Join("\n", rebuilt), new UTF8Encoding(false)); + task.Updated = today; } private static void UpsertFrontMatterField(List lines, ref int frontMatterEnd, string key, string value) @@ -644,14 +1044,27 @@ namespace Project.Tasks.Editor { return string.Format( CultureInfo.InvariantCulture, - "| {0} | {1} | {2} | {3} | {4} | `{5}` | {6} |", + "| {0} | {1} | {2} | {3} | {4} | {5} | `{6}` | {7} |", task.Id, task.Status, task.Priority, task.Area, + string.IsNullOrWhiteSpace(task.Owner) ? "unassigned" : task.Owner, task.ExecutionTime, NormalizePath(task.RelativeFilePath), - (task.Summary ?? string.Empty).Replace("|", "/")); + (task.IndexSummary ?? string.Empty).Replace("|", "/")); + } + + private static void DeleteIndexRow(string indexPath, TaskRecord task) + { + string[] lines = File.ReadAllLines(indexPath, Encoding.UTF8); + if (task.IndexLineNumber < 0 || task.IndexLineNumber >= lines.Length) + { + throw new InvalidOperationException("Не удалось найти строку задачи в Index.md."); + } + + var updated = lines.Where((line, index) => index != task.IndexLineNumber).ToArray(); + File.WriteAllText(indexPath, string.Join("\n", updated), new UTF8Encoding(false)); } private static string[] SplitMarkdownRow(string line) @@ -718,5 +1131,234 @@ namespace Project.Tasks.Editor int number; return int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out number) ? number : int.MaxValue; } + + private static void LoadOwnerPresets(TaskBoardData data) + { + if (data == null) + { + return; + } + + TaskBoardOwnersConfig config = EnsureOwnersConfig(data.OwnersConfigPath); + data.OwnerPresets.Clear(); + + if (config == null) + { + data.Warnings.Add("Не удалось загрузить owners config."); + return; + } + + foreach (string owner in config.owners) + { + if (!string.IsNullOrWhiteSpace(owner)) + { + data.OwnerPresets.Add(owner.Trim()); + } + } + + if (!data.OwnerPresets.Any(owner => string.Equals(owner, "unassigned", StringComparison.OrdinalIgnoreCase))) + { + data.OwnerPresets.Insert(0, "unassigned"); + } + } + + private static TaskBoardOwnersConfig EnsureOwnersConfig(string assetPath) + { + if (string.IsNullOrWhiteSpace(assetPath)) + { + return null; + } + + TaskBoardOwnersConfig config = AssetDatabase.LoadAssetAtPath(assetPath); + if (config != null) + { + return config; + } + + string directory = Path.GetDirectoryName(assetPath); + if (!string.IsNullOrEmpty(directory) && !AssetDatabase.IsValidFolder(directory)) + { + EnsureAssetFolder(directory); + } + + config = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(config, assetPath); + AssetDatabase.SaveAssets(); + AssetDatabase.Refresh(); + return config; + } + + private static void EnsureAssetFolder(string assetFolder) + { + string normalized = NormalizePath(assetFolder); + string[] parts = normalized.Split('/'); + string current = parts[0]; + + for (int i = 1; i < parts.Length; i++) + { + string next = current + "/" + parts[i]; + if (!AssetDatabase.IsValidFolder(next)) + { + AssetDatabase.CreateFolder(current, parts[i]); + } + + current = next; + } + } + + private static int ParseNumber(string value) + { + int number; + return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out number) ? number : 0; + } + + private static string BuildCanonicalExecutionTime(int totalMinutes) + { + int remaining = totalMinutes; + int weeks = remaining / TaskBoardConstants.MinutesPerWeek; + remaining %= TaskBoardConstants.MinutesPerWeek; + int days = remaining / TaskBoardConstants.MinutesPerDay; + remaining %= TaskBoardConstants.MinutesPerDay; + int hours = remaining / TaskBoardConstants.MinutesPerHour; + remaining %= TaskBoardConstants.MinutesPerHour; + int minutes = remaining; + + var builder = new StringBuilder(); + if (weeks > 0) + { + builder.Append(weeks).Append('w'); + } + + if (days > 0) + { + builder.Append(days).Append('d'); + } + + if (hours > 0) + { + builder.Append(hours).Append('h'); + } + + if (minutes > 0 || builder.Length == 0) + { + builder.Append(minutes).Append('m'); + } + + return builder.ToString(); + } + + private static string DeriveTitleFromPath(string relativeFilePath, string id) + { + if (string.IsNullOrWhiteSpace(relativeFilePath)) + { + return id; + } + + string fileName = Path.GetFileNameWithoutExtension(relativeFilePath) ?? id; + string prefix = id + "-"; + if (fileName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + fileName = fileName.Substring(prefix.Length); + } + + fileName = fileName.Replace('-', ' ').Trim(); + return string.IsNullOrWhiteSpace(fileName) ? id : fileName; + } + + private static string EscapeFrontMatterValue(string value) + { + return string.IsNullOrWhiteSpace(value) ? string.Empty : value.Replace("\r", " ").Replace("\n", " ").Trim(); + } + + private static ParsedTaskBody ParseBodySections(List lines, int bodyStart, TaskRecord task) + { + var parsed = new ParsedTaskBody(); + string currentHeading = null; + + for (int i = bodyStart; i < lines.Count; i++) + { + string line = lines[i]; + Match headingMatch = HeadingRegex.Match(line); + if (headingMatch.Success) + { + currentHeading = headingMatch.Groups[1].Value.Trim(); + if (!parsed.OrderedHeadings.Contains(currentHeading)) + { + parsed.OrderedHeadings.Add(currentHeading); + } + + if (!parsed.ValuesByHeading.ContainsKey(currentHeading)) + { + parsed.ValuesByHeading[currentHeading] = string.Empty; + } + continue; + } + + if (H1Regex.IsMatch(line) || string.IsNullOrEmpty(currentHeading)) + { + continue; + } + + if (parsed.ValuesByHeading[currentHeading].Length > 0) + { + parsed.ValuesByHeading[currentHeading] += "\n"; + } + + parsed.ValuesByHeading[currentHeading] += line; + } + + foreach (string heading in EditableSectionOrder) + { + if (!parsed.ValuesByHeading.ContainsKey(heading)) + { + parsed.ValuesByHeading[heading] = GetEditableSectionValue(task, heading); + } + } + + return parsed; + } + + private static void AddSectionContent(List lines, string content) + { + if (string.IsNullOrEmpty(content)) + { + return; + } + + lines.AddRange(content.Replace("\r\n", "\n").Split('\n')); + } + + private static string GetEditableSectionValue(TaskRecord task, string heading) + { + switch (heading) + { + case "Why": + return task.Why ?? string.Empty; + case "Expected Outcome": + return task.ExpectedOutcome ?? string.Empty; + case "Current Context": + return task.CurrentContext ?? string.Empty; + case "Acceptance Criteria": + return task.AcceptanceCriteria ?? string.Empty; + case "Verification": + return task.Verification ?? string.Empty; + case "Risks / Open Questions": + return task.Risks ?? string.Empty; + case "Human Decisions Needed": + return task.HumanDecisions ?? string.Empty; + case "Decision Log": + return task.DecisionLog ?? string.Empty; + case "Handoff Notes": + return task.HandoffNotes ?? string.Empty; + default: + return string.Empty; + } + } + + private sealed class ParsedTaskBody + { + public readonly List OrderedHeadings = new List(); + public readonly Dictionary ValuesByHeading = new Dictionary(StringComparer.OrdinalIgnoreCase); + } } } diff --git a/Assets/Editor/Tasks/TaskBoardWindow.cs b/Assets/Editor/Tasks/TaskBoardWindow.cs index b3311d64..2b253b54 100644 --- a/Assets/Editor/Tasks/TaskBoardWindow.cs +++ b/Assets/Editor/Tasks/TaskBoardWindow.cs @@ -9,15 +9,28 @@ namespace Project.Tasks.Editor internal sealed class TaskBoardWindow : EditorWindow { private const float ColumnWidth = 290f; - private const float DetailsWidth = 420f; + private const float DefaultDetailsWidth = 420f; + private const float MinDetailsWidth = 320f; + private const float MinBoardWidth = 420f; + private const float SplitterWidth = 5f; + private const string CustomOwnerOption = ""; + private const string DetailsWidthPrefsKey = "Project.Tasks.Editor.TaskBoardWindow.DetailsWidth"; + private const double DeleteConfirmTimeoutSeconds = 3.0; private TaskBoardData data; private Vector2 boardScroll; private Vector2 detailsScroll; private string searchText = string.Empty; - private string areaFilter = "All"; - private string priorityFilter = "All"; - private bool showDone = true; + private string sortOption = "Priority Desc"; + private bool filtersExpanded = true; + + private readonly HashSet selectedStatuses = new HashSet(StringComparer.OrdinalIgnoreCase); + private readonly HashSet selectedPriorities = new HashSet(StringComparer.OrdinalIgnoreCase); + private readonly HashSet selectedAreas = new HashSet(StringComparer.OrdinalIgnoreCase); + private readonly HashSet selectedOwners = new HashSet(StringComparer.OrdinalIgnoreCase); + private readonly HashSet selectedFileStates = new HashSet(StringComparer.OrdinalIgnoreCase); + private readonly HashSet selectedWarningStates = new HashSet(StringComparer.OrdinalIgnoreCase); + private TaskRecord selectedTask; private string saveMessage = string.Empty; private MessageType saveMessageType = MessageType.Info; @@ -25,12 +38,28 @@ namespace Project.Tasks.Editor private string editStatus; private string editPriority; private string editOwner; + private bool editOwnerIsCustom; private string editExecutionTime; - private string editSummary; + private string editIndexSummary; + private string editTaskSummary; + private string editWhy; + private string editExpectedOutcome; + private string editCurrentContext; + private string editAcceptanceCriteria; + private string editVerification; + private string editRisks; + private string editHumanDecisions; + private string editDecisionLog; + private string editHandoffNotes; + private GUIStyle readOnlyWrappedTextArea; private GUIStyle priorityBadgeStyle; private TaskRecord pressedTask; private Vector2 pressedMousePosition; + private float detailsWidth = DefaultDetailsWidth; + private bool isResizingDetails; + private string pendingDeleteTaskId; + private double pendingDeleteUntil; [MenuItem("Tools/Tasks/Kanban Board")] public static void Open() @@ -40,11 +69,18 @@ namespace Project.Tasks.Editor private void OnEnable() { + detailsWidth = EditorPrefs.GetFloat(DetailsWidthPrefsKey, DefaultDetailsWidth); Reload(); } + private void OnDisable() + { + EditorPrefs.SetFloat(DetailsWidthPrefsKey, detailsWidth); + } + private void OnGUI() { + ResetDeleteConfirmationIfExpired(); DrawToolbar(); if (data == null) @@ -63,9 +99,19 @@ namespace Project.Tasks.Editor EditorGUILayout.HelpBox(saveMessage, saveMessageType); } + DrawSummaryBar(); + DrawFilterPanel(); + + float clampedDetailsWidth = Mathf.Clamp(detailsWidth, MinDetailsWidth, Mathf.Max(MinDetailsWidth, position.width - MinBoardWidth)); + if (!Mathf.Approximately(clampedDetailsWidth, detailsWidth)) + { + detailsWidth = clampedDetailsWidth; + } + EditorGUILayout.BeginHorizontal(); - DrawBoard(); - DrawDetails(); + DrawBoard(position.width - detailsWidth - SplitterWidth - 24f); + DrawDetailsSplitter(); + DrawDetails(detailsWidth); EditorGUILayout.EndHorizontal(); } @@ -75,7 +121,7 @@ namespace Project.Tasks.Editor if (GUILayout.Button("Reload", EditorStyles.toolbarButton, GUILayout.Width(60f))) { - Reload(); + Reload(selectedTask != null ? selectedTask.Id : null); } if (GUILayout.Button("Open Index", EditorStyles.toolbarButton, GUILayout.Width(80f))) @@ -84,52 +130,123 @@ namespace Project.Tasks.Editor TaskBoardService.OpenInDefaultApp(indexPath); } - GUILayout.Space(8f); - GUILayout.Label("Search", GUILayout.Width(45f)); - searchText = GUILayout.TextField(searchText, GUI.skin.FindStyle("ToolbarSeachTextField") ?? EditorStyles.toolbarTextField, GUILayout.MinWidth(150f)); + if (GUILayout.Button("Owners Config", EditorStyles.toolbarButton, GUILayout.Width(95f))) + { + OpenOwnersConfig(); + } GUILayout.Space(8f); - areaFilter = DrawFilterPopup("Area", areaFilter, BuildAreaOptions(), 120f); - priorityFilter = DrawFilterPopup("Priority", priorityFilter, BuildPriorityOptions(), 110f); - showDone = GUILayout.Toggle(showDone, "Show Done", EditorStyles.toolbarButton, GUILayout.Width(85f)); + GUILayout.Label("Search", GUILayout.Width(45f)); + searchText = GUILayout.TextField(searchText, GUI.skin.FindStyle("ToolbarSeachTextField") ?? EditorStyles.toolbarTextField, GUILayout.MinWidth(180f)); GUILayout.FlexibleSpace(); EditorGUILayout.EndHorizontal(); } - private string DrawFilterPopup(string label, string currentValue, string[] options, float width) + private void DrawSummaryBar() { - GUILayout.Label(label, GUILayout.Width(36f)); - int currentIndex = Array.IndexOf(options, currentValue); - if (currentIndex < 0) + if (data == null) { - currentIndex = 0; + return; } - int nextIndex = EditorGUILayout.Popup(currentIndex, options, EditorStyles.toolbarPopup, GUILayout.Width(width)); - return options[Mathf.Clamp(nextIndex, 0, options.Length - 1)]; + List filteredTasks = GetFilteredTasks(); + int totalMinutes = TaskBoardService.SumEstimatedMinutes(filteredTasks); + int warnings = filteredTasks.Count(task => task.ValidationMessages.Count > 0); + int missingFiles = filteredTasks.Count(task => !task.FileExists); + + EditorGUILayout.BeginVertical("box"); + EditorGUILayout.LabelField( + "Visible Tasks: " + filteredTasks.Count + " / " + data.Tasks.Count + + " | Visible Time: " + TaskBoardService.FormatMinutesForDisplay(totalMinutes) + + " | Warnings: " + warnings + + " | Missing Files: " + missingFiles, + EditorStyles.wordWrappedMiniLabel); + + EditorGUILayout.BeginHorizontal(); + GUILayout.Label("Sort", GUILayout.Width(32f)); + sortOption = DrawPopup(sortOption, BuildSortOptions(), 160f); + GUILayout.FlexibleSpace(); + EditorGUILayout.EndHorizontal(); + EditorGUILayout.EndVertical(); } - private void DrawBoard() + private void DrawFilterPanel() + { + EditorGUILayout.BeginVertical("box"); + filtersExpanded = EditorGUILayout.Foldout(filtersExpanded, "Filters", true); + if (filtersExpanded) + { + DrawSelectionGroup("Statuses", TaskBoardConstants.Statuses, selectedStatuses); + DrawSelectionGroup("Priorities", TaskBoardConstants.Priorities, selectedPriorities); + DrawSelectionGroup("Areas", BuildAreaOptions(), selectedAreas); + DrawSelectionGroup("Owners", BuildOwnerFilterOptions(), selectedOwners); + DrawSelectionGroup("Files", new[] { "Existing", "Missing" }, selectedFileStates); + DrawSelectionGroup("Warnings", new[] { "Warnings", "Clean" }, selectedWarningStates); + } + EditorGUILayout.EndVertical(); + } + + private void DrawSelectionGroup(string label, string[] options, HashSet selectedValues) + { + if (options == null || options.Length == 0) + { + return; + } + + EditorGUILayout.BeginVertical("box"); + EditorGUILayout.BeginHorizontal(); + EditorGUILayout.LabelField(label, EditorStyles.boldLabel); + if (GUILayout.Button("All", GUILayout.Width(45f))) + { + SetAll(selectedValues, options, true); + } + + if (GUILayout.Button("Nothing", GUILayout.Width(65f))) + { + selectedValues.Clear(); + } + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.BeginHorizontal(); + for (int i = 0; i < options.Length; i++) + { + string option = options[i]; + bool current = selectedValues.Contains(option); + bool next = GUILayout.Toggle(current, option, "Button"); + if (next != current) + { + if (next) + { + selectedValues.Add(option); + } + else + { + selectedValues.Remove(option); + } + } + + if ((i + 1) % 4 == 0) + { + EditorGUILayout.EndHorizontal(); + EditorGUILayout.BeginHorizontal(); + } + } + EditorGUILayout.EndHorizontal(); + EditorGUILayout.EndVertical(); + } + + private void DrawBoard(float width) { List filteredTasks = GetFilteredTasks(); - EditorGUILayout.BeginVertical(GUILayout.ExpandWidth(true)); + EditorGUILayout.BeginVertical(GUILayout.Width(Mathf.Max(MinBoardWidth, width)), GUILayout.ExpandHeight(true)); boardScroll = EditorGUILayout.BeginScrollView(boardScroll, GUILayout.ExpandWidth(true), GUILayout.ExpandHeight(true)); EditorGUILayout.BeginHorizontal(); foreach (string status in TaskBoardConstants.Statuses) { - if (!showDone && string.Equals(status, "done", StringComparison.Ordinal)) - { - continue; - } - - DrawColumn(status, filteredTasks - .Where(task => string.Equals(task.Status, status, StringComparison.Ordinal)) - .OrderBy(task => TaskBoardService.GetPrioritySortOrder(task.Priority)) - .ThenBy(task => task.Id, StringComparer.OrdinalIgnoreCase) - .ToList()); + DrawColumn(status, filteredTasks.Where(task => string.Equals(task.Status, status, StringComparison.OrdinalIgnoreCase)).ToList()); } EditorGUILayout.EndHorizontal(); @@ -140,8 +257,9 @@ namespace Project.Tasks.Editor private void DrawColumn(string status, List tasks) { EditorGUILayout.BeginVertical("box", GUILayout.Width(ColumnWidth), GUILayout.ExpandHeight(true)); - EditorGUILayout.LabelField(ObjectNames.NicifyVariableName(status), EditorStyles.boldLabel); + EditorGUILayout.LabelField(status, EditorStyles.boldLabel); EditorGUILayout.LabelField(tasks.Count + " task(s)", EditorStyles.miniLabel); + EditorGUILayout.LabelField(TaskBoardService.FormatMinutesForDisplay(TaskBoardService.SumEstimatedMinutes(tasks)), EditorStyles.miniLabel); EditorGUILayout.Space(6f); Rect headerRect = GUILayoutUtility.GetLastRect(); @@ -183,14 +301,14 @@ namespace Project.Tasks.Editor Rect firstRect = GUILayoutUtility.GetLastRect(); EditorGUILayout.LabelField(string.IsNullOrEmpty(task.Title) ? task.Id : task.Title, EditorStyles.wordWrappedLabel); - if (!string.IsNullOrEmpty(task.Summary)) + if (!string.IsNullOrEmpty(task.IndexSummary)) { - EditorGUILayout.LabelField(task.Summary, EditorStyles.wordWrappedMiniLabel); + EditorGUILayout.LabelField(task.IndexSummary, EditorStyles.wordWrappedMiniLabel); } EditorGUILayout.Space(4f); EditorGUILayout.LabelField("Area: " + SafeValue(task.Area) + " Owner: " + SafeValue(task.Owner), EditorStyles.miniLabel); - EditorGUILayout.LabelField("Time: " + SafeValue(task.ExecutionTime), EditorStyles.miniLabel); + EditorGUILayout.LabelField("Time: " + GetFormattedTime(task), EditorStyles.miniLabel); if (task.ValidationMessages.Count > 0) { @@ -209,9 +327,9 @@ namespace Project.Tasks.Editor HandleCardDrag(task, cardRect); } - private void DrawDetails() + private void DrawDetails(float width) { - EditorGUILayout.BeginVertical("box", GUILayout.Width(DetailsWidth), GUILayout.ExpandHeight(true)); + EditorGUILayout.BeginVertical("box", GUILayout.Width(width), GUILayout.ExpandHeight(true)); EditorGUILayout.LabelField("Task Details", EditorStyles.boldLabel); if (selectedTask == null) @@ -231,6 +349,11 @@ namespace Project.Tasks.Editor EditorGUILayout.HelpBox(string.Join("\n", selectedTask.ValidationMessages), MessageType.Warning); } + if (!selectedTask.FileExists) + { + EditorGUILayout.HelpBox("Task-файл отсутствует. Поля из task-файла недоступны до создания файла по шаблону.", MessageType.Info); + } + EditorGUILayout.BeginHorizontal(); using (new EditorGUI.DisabledScope(!selectedTask.FileExists)) { @@ -254,6 +377,9 @@ namespace Project.Tasks.Editor } EditorGUILayout.EndHorizontal(); + EditorGUILayout.Space(4f); + DrawDeleteButton(); + EditorGUILayout.Space(8f); DrawEditableFields(); EditorGUILayout.Space(12f); @@ -262,21 +388,23 @@ namespace Project.Tasks.Editor EditorGUILayout.LabelField("Priority", GUILayout.Width(80f)); DrawPriorityBadge(selectedTask.Priority); EditorGUILayout.EndHorizontal(); + DrawReadOnlyField("Estimate", GetFormattedTime(selectedTask)); DrawReadOnlyField("File", selectedTask.RelativeFilePath); DrawReadOnlyField("Created", selectedTask.Created); DrawReadOnlyField("Updated", selectedTask.Updated); DrawReadOnlyField("Area", selectedTask.Area); EditorGUILayout.Space(10f); - DrawSection("Why", selectedTask.Why); - DrawSection("Expected Outcome", selectedTask.ExpectedOutcome); - DrawSection("Current Context", selectedTask.CurrentContext); - DrawSection("Acceptance Criteria", selectedTask.AcceptanceCriteria); - DrawSection("Verification", selectedTask.Verification); - DrawSection("Risks / Open Questions", selectedTask.Risks); - DrawSection("Human Decisions Needed", selectedTask.HumanDecisions); - DrawSection("Decision Log", selectedTask.DecisionLog); - DrawSection("Handoff Notes", selectedTask.HandoffNotes); + EditorGUILayout.LabelField("Task File Sections", EditorStyles.boldLabel); + DrawEditableSection("Why", ref editWhy, selectedTask.FileExists); + DrawEditableSection("Expected Outcome", ref editExpectedOutcome, selectedTask.FileExists); + DrawEditableSection("Current Context", ref editCurrentContext, selectedTask.FileExists); + DrawEditableSection("Acceptance Criteria", ref editAcceptanceCriteria, selectedTask.FileExists); + DrawEditableSection("Verification", ref editVerification, selectedTask.FileExists); + DrawEditableSection("Risks / Open Questions", ref editRisks, selectedTask.FileExists); + DrawEditableSection("Human Decisions Needed", ref editHumanDecisions, selectedTask.FileExists); + DrawEditableSection("Decision Log", ref editDecisionLog, selectedTask.FileExists); + DrawEditableSection("Handoff Notes", ref editHandoffNotes, selectedTask.FileExists); EditorGUILayout.EndScrollView(); EditorGUILayout.EndVertical(); @@ -284,19 +412,36 @@ namespace Project.Tasks.Editor private void DrawEditableFields() { - EditorGUILayout.LabelField("Quick Edit", EditorStyles.boldLabel); - + EditorGUILayout.LabelField("Index Fields", EditorStyles.boldLabel); editStatus = DrawStringPopup("Status", editStatus, TaskBoardConstants.Statuses); editPriority = DrawStringPopup("Priority", editPriority, TaskBoardConstants.Priorities); - editOwner = EditorGUILayout.TextField("Owner", editOwner ?? string.Empty); + DrawOwnerField(); editExecutionTime = EditorGUILayout.TextField("Execution Time", editExecutionTime ?? string.Empty); - EditorGUILayout.LabelField("Summary"); - editSummary = EditorGUILayout.TextArea(editSummary ?? string.Empty, GUILayout.MinHeight(60f)); + string normalizedExecutionTime; + int estimatedMinutes; + if (TaskBoardService.TryNormalizeExecutionTime(editExecutionTime, out normalizedExecutionTime, out estimatedMinutes)) + { + editExecutionTime = normalizedExecutionTime; + } + + EditorGUILayout.LabelField("Estimate", TaskBoardService.IsValidExecutionTime(editExecutionTime) + ? TaskBoardService.FormatExecutionTimeForDisplay(editExecutionTime) + : "-"); + + EditorGUILayout.LabelField("Index Summary"); + editIndexSummary = EditorGUILayout.TextArea(editIndexSummary ?? string.Empty, GUILayout.MinHeight(60f)); + + EditorGUILayout.Space(8f); + EditorGUILayout.LabelField("Task File Fields", EditorStyles.boldLabel); + using (new EditorGUI.DisabledScope(!selectedTask.FileExists)) + { + EditorGUILayout.LabelField("Task Summary"); + editTaskSummary = EditorGUILayout.TextArea(editTaskSummary ?? string.Empty, GUILayout.MinHeight(60f)); + } bool hasChanges = HasChanges(); bool executionTimeValid = TaskBoardService.IsValidExecutionTime(editExecutionTime); - if (!executionTimeValid) { EditorGUILayout.HelpBox("execution_time должен быть в формате Jira, например 1d6h30m, и быть кратным 30 минутам.", MessageType.Warning); @@ -311,6 +456,31 @@ namespace Project.Tasks.Editor } } + private void DrawDeleteButton() + { + if (selectedTask == null) + { + return; + } + + bool isPending = string.Equals(pendingDeleteTaskId, selectedTask.Id, StringComparison.OrdinalIgnoreCase) && EditorApplication.timeSinceStartup <= pendingDeleteUntil; + Color previous = GUI.backgroundColor; + GUI.backgroundColor = isPending ? new Color(0.85f, 0.3f, 0.3f) : new Color(0.7f, 0.25f, 0.25f); + if (GUILayout.Button(isPending ? "Sure?" : "Delete Task")) + { + if (isPending) + { + DeleteSelectedTask(); + } + else + { + pendingDeleteTaskId = selectedTask.Id; + pendingDeleteUntil = EditorApplication.timeSinceStartup + DeleteConfirmTimeoutSeconds; + } + } + GUI.backgroundColor = previous; + } + private string DrawStringPopup(string label, string currentValue, string[] options) { int index = Array.IndexOf(options, currentValue); @@ -323,22 +493,29 @@ namespace Project.Tasks.Editor return options[Mathf.Clamp(nextIndex, 0, options.Length - 1)]; } + private string DrawPopup(string currentValue, string[] options, float width) + { + int index = Array.IndexOf(options, currentValue); + if (index < 0) + { + index = 0; + } + + int nextIndex = EditorGUILayout.Popup(index, options, EditorStyles.toolbarPopup, GUILayout.Width(width)); + return options[Mathf.Clamp(nextIndex, 0, options.Length - 1)]; + } + private void DrawReadOnlyField(string label, string value) { EditorGUILayout.LabelField(label, string.IsNullOrEmpty(value) ? "-" : value); } - private void DrawSection(string title, string content) + private void DrawEditableSection(string title, ref string content, bool enabled) { - if (string.IsNullOrWhiteSpace(content)) - { - return; - } - EditorGUILayout.LabelField(title, EditorStyles.boldLabel); - using (new EditorGUI.DisabledScope(true)) + using (new EditorGUI.DisabledScope(!enabled)) { - EditorGUILayout.TextArea(content, GetReadOnlyWrappedTextArea(), GUILayout.MinHeight(52f)); + content = EditorGUILayout.TextArea(enabled ? (content ?? string.Empty) : string.Empty, GetReadOnlyWrappedTextArea(), GUILayout.MinHeight(52f)); } EditorGUILayout.Space(6f); } @@ -347,10 +524,7 @@ namespace Project.Tasks.Editor { if (readOnlyWrappedTextArea == null) { - readOnlyWrappedTextArea = new GUIStyle(EditorStyles.textArea) - { - wordWrap = true, - }; + readOnlyWrappedTextArea = new GUIStyle(EditorStyles.textArea) { wordWrap = true }; } return readOnlyWrappedTextArea; @@ -380,6 +554,32 @@ namespace Project.Tasks.Editor GUI.Label(rect, priority, style); } + private void DrawOwnerField() + { + string[] presetOptions = BuildOwnerPresetOptions(); + int currentIndex = GetOwnerPopupIndex(presetOptions); + int nextIndex = EditorGUILayout.Popup("Owner", currentIndex, presetOptions); + + if (nextIndex >= 0 && nextIndex < presetOptions.Length) + { + string selectedValue = presetOptions[nextIndex]; + if (string.Equals(selectedValue, CustomOwnerOption, StringComparison.Ordinal)) + { + editOwnerIsCustom = true; + } + else + { + editOwnerIsCustom = false; + editOwner = selectedValue; + } + } + + if (editOwnerIsCustom) + { + editOwner = EditorGUILayout.TextField("Custom Owner", editOwner ?? string.Empty); + } + } + private void HandleCardDrag(TaskRecord task, Rect cardRect) { Event current = Event.current; @@ -473,19 +673,32 @@ namespace Project.Tasks.Editor saveMessage = task.FileExists ? "Статус задачи обновлен перетаскиванием." - : "Статус задачи обновлен в Index.md. Task-файл не найден, поэтому его метаданные не обновлялись."; + : "Статус задачи обновлен в Index.md. Task-файл не найден, поэтому поля task-файла не обновлялись."; saveMessageType = MessageType.Info; Reload(selectedId); } private void SelectTask(TaskRecord task) { + ResetDeleteConfirmation(); selectedTask = task; - editStatus = task.Status; - editPriority = task.Priority; - editOwner = task.Owner; - editExecutionTime = task.ExecutionTime; - editSummary = task.Summary; + TaskBoardService.LoadTaskDetails(selectedTask); + editStatus = selectedTask.Status; + editPriority = selectedTask.Priority; + editOwner = selectedTask.Owner; + editOwnerIsCustom = data != null && !data.OwnerPresets.Any(owner => string.Equals(owner, selectedTask.Owner, StringComparison.OrdinalIgnoreCase)); + editExecutionTime = TaskBoardService.NormalizeExecutionTime(selectedTask.ExecutionTime); + editIndexSummary = selectedTask.IndexSummary; + editTaskSummary = selectedTask.TaskSummary; + editWhy = selectedTask.Why; + editExpectedOutcome = selectedTask.ExpectedOutcome; + editCurrentContext = selectedTask.CurrentContext; + editAcceptanceCriteria = selectedTask.AcceptanceCriteria; + editVerification = selectedTask.Verification; + editRisks = selectedTask.Risks; + editHumanDecisions = selectedTask.HumanDecisions; + editDecisionLog = selectedTask.DecisionLog; + editHandoffNotes = selectedTask.HandoffNotes; } private void SaveSelectedTask() @@ -496,11 +709,24 @@ namespace Project.Tasks.Editor } string selectedId = selectedTask.Id; - selectedTask.Status = editStatus; + selectedTask.Status = TaskBoardService.NormalizeStatus(editStatus); selectedTask.Priority = editPriority; selectedTask.Owner = string.IsNullOrWhiteSpace(editOwner) ? "unassigned" : editOwner.Trim(); - selectedTask.ExecutionTime = editExecutionTime.Trim(); - selectedTask.Summary = (editSummary ?? string.Empty).Trim(); + selectedTask.ExecutionTime = TaskBoardService.NormalizeExecutionTime(editExecutionTime); + selectedTask.IndexSummary = (editIndexSummary ?? string.Empty).Trim(); + if (selectedTask.FileExists) + { + selectedTask.TaskSummary = (editTaskSummary ?? string.Empty).Trim(); + selectedTask.Why = (editWhy ?? string.Empty).Trim(); + selectedTask.ExpectedOutcome = (editExpectedOutcome ?? string.Empty).Trim(); + selectedTask.CurrentContext = (editCurrentContext ?? string.Empty).Trim(); + selectedTask.AcceptanceCriteria = (editAcceptanceCriteria ?? string.Empty).Trim(); + selectedTask.Verification = (editVerification ?? string.Empty).Trim(); + selectedTask.Risks = (editRisks ?? string.Empty).Trim(); + selectedTask.HumanDecisions = (editHumanDecisions ?? string.Empty).Trim(); + selectedTask.DecisionLog = (editDecisionLog ?? string.Empty).Trim(); + selectedTask.HandoffNotes = (editHandoffNotes ?? string.Empty).Trim(); + } string error; if (!TaskBoardService.Save(data, selectedTask, out error)) @@ -512,7 +738,7 @@ namespace Project.Tasks.Editor saveMessage = selectedTask.FileExists ? "Изменения сохранены в Index.md и task-файл." - : "Изменения сохранены в Index.md. Task-файл не найден, поэтому его метаданные не обновлялись."; + : "Изменения сохранены в Index.md. Task-файл не найден, поэтому поля task-файла не обновлялись."; saveMessageType = MessageType.Info; Reload(selectedId); } @@ -538,6 +764,29 @@ namespace Project.Tasks.Editor Reload(selectedId); } + private void DeleteSelectedTask() + { + if (selectedTask == null) + { + return; + } + + string deletedId = selectedTask.Id; + string error; + if (!TaskBoardService.DeleteTask(data, selectedTask, out error)) + { + saveMessage = "Не удалось удалить задачу: " + error; + saveMessageType = MessageType.Error; + return; + } + + ResetDeleteConfirmation(); + saveMessage = "Задача " + deletedId + " удалена."; + saveMessageType = MessageType.Info; + selectedTask = null; + Reload(); + } + private bool HasChanges() { if (selectedTask == null) @@ -545,19 +794,39 @@ namespace Project.Tasks.Editor return false; } + bool taskSummaryChanged = selectedTask.FileExists && !string.Equals(selectedTask.TaskSummary ?? string.Empty, editTaskSummary ?? string.Empty, StringComparison.Ordinal); + bool taskBodyChanged = selectedTask.FileExists + && (!string.Equals(selectedTask.Why ?? string.Empty, editWhy ?? string.Empty, StringComparison.Ordinal) + || !string.Equals(selectedTask.ExpectedOutcome ?? string.Empty, editExpectedOutcome ?? string.Empty, StringComparison.Ordinal) + || !string.Equals(selectedTask.CurrentContext ?? string.Empty, editCurrentContext ?? string.Empty, StringComparison.Ordinal) + || !string.Equals(selectedTask.AcceptanceCriteria ?? string.Empty, editAcceptanceCriteria ?? string.Empty, StringComparison.Ordinal) + || !string.Equals(selectedTask.Verification ?? string.Empty, editVerification ?? string.Empty, StringComparison.Ordinal) + || !string.Equals(selectedTask.Risks ?? string.Empty, editRisks ?? string.Empty, StringComparison.Ordinal) + || !string.Equals(selectedTask.HumanDecisions ?? string.Empty, editHumanDecisions ?? string.Empty, StringComparison.Ordinal) + || !string.Equals(selectedTask.DecisionLog ?? string.Empty, editDecisionLog ?? string.Empty, StringComparison.Ordinal) + || !string.Equals(selectedTask.HandoffNotes ?? string.Empty, editHandoffNotes ?? string.Empty, StringComparison.Ordinal)); return !string.Equals(selectedTask.Status, editStatus, StringComparison.Ordinal) || !string.Equals(selectedTask.Priority, editPriority, StringComparison.Ordinal) || !string.Equals(selectedTask.Owner ?? string.Empty, editOwner ?? string.Empty, StringComparison.Ordinal) - || !string.Equals(selectedTask.ExecutionTime ?? string.Empty, editExecutionTime ?? string.Empty, StringComparison.Ordinal) - || !string.Equals(selectedTask.Summary ?? string.Empty, editSummary ?? string.Empty, StringComparison.Ordinal); + || !string.Equals(selectedTask.ExecutionTime ?? string.Empty, TaskBoardService.NormalizeExecutionTime(editExecutionTime), StringComparison.Ordinal) + || !string.Equals(selectedTask.IndexSummary ?? string.Empty, editIndexSummary ?? string.Empty, StringComparison.Ordinal) + || taskSummaryChanged + || taskBodyChanged; } private void Reload(string selectedId = null) { data = TaskBoardService.Load(); + SyncSelections(selectedStatuses, TaskBoardConstants.Statuses); + SyncSelections(selectedPriorities, TaskBoardConstants.Priorities); + SyncSelections(selectedAreas, BuildAreaOptions()); + SyncSelections(selectedOwners, BuildOwnerFilterOptions()); + SyncSelections(selectedFileStates, new[] { "Existing", "Missing" }); + SyncSelections(selectedWarningStates, new[] { "Warnings", "Clean" }); if (data == null || data.Tasks.Count == 0) { + ResetDeleteConfirmation(); selectedTask = null; return; } @@ -575,7 +844,23 @@ namespace Project.Tasks.Editor if (nextSelection == null) { - nextSelection = data.Tasks[0]; + selectedTask = null; + editStatus = string.Empty; + editPriority = string.Empty; + editOwner = string.Empty; + editExecutionTime = string.Empty; + editIndexSummary = string.Empty; + editTaskSummary = string.Empty; + editWhy = string.Empty; + editExpectedOutcome = string.Empty; + editCurrentContext = string.Empty; + editAcceptanceCriteria = string.Empty; + editVerification = string.Empty; + editRisks = string.Empty; + editHumanDecisions = string.Empty; + editDecisionLog = string.Empty; + editHandoffNotes = string.Empty; + return; } SelectTask(nextSelection); @@ -583,7 +868,7 @@ namespace Project.Tasks.Editor private List GetFilteredTasks() { - IEnumerable query = data.Tasks; + IEnumerable query = data != null ? data.Tasks : Enumerable.Empty(); if (!string.IsNullOrWhiteSpace(searchText)) { @@ -591,39 +876,187 @@ namespace Project.Tasks.Editor query = query.Where(task => ContainsIgnoreCase(task.Id, search) || ContainsIgnoreCase(task.Title, search) - || ContainsIgnoreCase(task.Summary, search) + || ContainsIgnoreCase(task.IndexSummary, search) || ContainsIgnoreCase(task.Area, search) || ContainsIgnoreCase(task.Owner, search)); } - if (!string.Equals(areaFilter, "All", StringComparison.Ordinal)) - { - query = query.Where(task => string.Equals(task.Area, areaFilter, StringComparison.OrdinalIgnoreCase)); - } + query = query.Where(task => selectedStatuses.Contains(task.Status)); + query = query.Where(task => selectedPriorities.Contains(task.Priority)); + query = query.Where(task => selectedAreas.Contains(string.IsNullOrWhiteSpace(task.Area) ? "-" : task.Area)); + query = query.Where(task => selectedOwners.Contains(string.IsNullOrWhiteSpace(task.Owner) ? "unassigned" : task.Owner)); + query = query.Where(task => selectedFileStates.Contains(task.FileExists ? "Existing" : "Missing")); + query = query.Where(task => selectedWarningStates.Contains(task.ValidationMessages.Count > 0 ? "Warnings" : "Clean")); - if (!string.Equals(priorityFilter, "All", StringComparison.Ordinal)) - { - query = query.Where(task => string.Equals(task.Priority, priorityFilter, StringComparison.OrdinalIgnoreCase)); - } - - return query.ToList(); + return ApplySort(query).ToList(); } private string[] BuildAreaOptions() { - if (data == null || data.Tasks.Count == 0) + if (data == null) { - return new[] { "All" }; + return Array.Empty(); } - List options = new List { "All" }; - options.AddRange(data.Tasks.Select(task => task.Area).Where(area => !string.IsNullOrWhiteSpace(area)).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(area => area, StringComparer.OrdinalIgnoreCase)); - return options.ToArray(); + return data.Tasks + .Select(task => string.IsNullOrWhiteSpace(task.Area) ? "-" : task.Area) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(area => area, StringComparer.OrdinalIgnoreCase) + .ToArray(); } - private string[] BuildPriorityOptions() + private string[] BuildOwnerFilterOptions() { - return new[] { "All", "Lowest", "Low", "Medium", "High", "Highest" }; + return TaskBoardService.BuildOwnerOptions(data, data != null ? data.Tasks : null); + } + + private string[] BuildOwnerPresetOptions() + { + var options = new List(); + if (data != null) + { + options.AddRange(data.OwnerPresets.Where(owner => !string.IsNullOrWhiteSpace(owner))); + } + + if (!string.IsNullOrWhiteSpace(editOwner) && !options.Any(owner => string.Equals(owner, editOwner, StringComparison.OrdinalIgnoreCase)) && !editOwnerIsCustom) + { + options.Insert(0, editOwner); + } + + options.Add(CustomOwnerOption); + return options.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); + } + + private string[] BuildSortOptions() + { + return new[] + { + "Priority Desc", + "Priority Asc", + "Execution Time Desc", + "Execution Time Asc", + "ID Asc", + "ID Desc", + "Owner Asc", + "Title Asc", + "Area Asc", + }; + } + + private IEnumerable ApplySort(IEnumerable tasks) + { + switch (sortOption) + { + case "Priority Asc": + return tasks.OrderByDescending(task => TaskBoardService.GetPrioritySortOrder(task.Priority)).ThenBy(task => task.Id, StringComparer.OrdinalIgnoreCase); + case "Execution Time Desc": + return tasks.OrderByDescending(task => task.EstimatedMinutes).ThenBy(task => task.Id, StringComparer.OrdinalIgnoreCase); + case "Execution Time Asc": + return tasks.OrderBy(task => task.EstimatedMinutes < 0 ? int.MaxValue : task.EstimatedMinutes).ThenBy(task => task.Id, StringComparer.OrdinalIgnoreCase); + case "ID Desc": + return tasks.OrderByDescending(task => task.Id, StringComparer.OrdinalIgnoreCase); + case "Owner Asc": + return tasks.OrderBy(task => task.Owner, StringComparer.OrdinalIgnoreCase).ThenBy(task => task.Id, StringComparer.OrdinalIgnoreCase); + case "Title Asc": + return tasks.OrderBy(task => task.Title, StringComparer.OrdinalIgnoreCase).ThenBy(task => task.Id, StringComparer.OrdinalIgnoreCase); + case "Area Asc": + return tasks.OrderBy(task => task.Area, StringComparer.OrdinalIgnoreCase).ThenBy(task => task.Id, StringComparer.OrdinalIgnoreCase); + case "ID Asc": + return tasks.OrderBy(task => task.Id, StringComparer.OrdinalIgnoreCase); + case "Priority Desc": + default: + return tasks.OrderBy(task => TaskBoardService.GetPrioritySortOrder(task.Priority)).ThenBy(task => task.Id, StringComparer.OrdinalIgnoreCase); + } + } + + private void DrawDetailsSplitter() + { + Rect splitterRect = GUILayoutUtility.GetRect(SplitterWidth, 1f, GUILayout.Width(SplitterWidth), GUILayout.ExpandHeight(true)); + EditorGUIUtility.AddCursorRect(splitterRect, MouseCursor.ResizeHorizontal); + EditorGUI.DrawRect(splitterRect, new Color(0.2f, 0.2f, 0.2f, 0.75f)); + + Event current = Event.current; + switch (current.type) + { + case EventType.MouseDown: + if (splitterRect.Contains(current.mousePosition)) + { + isResizingDetails = true; + current.Use(); + } + break; + case EventType.MouseDrag: + if (isResizingDetails) + { + detailsWidth = Mathf.Clamp(position.width - current.mousePosition.x, MinDetailsWidth, Mathf.Max(MinDetailsWidth, position.width - MinBoardWidth)); + Repaint(); + current.Use(); + } + break; + case EventType.MouseUp: + if (isResizingDetails) + { + isResizingDetails = false; + EditorPrefs.SetFloat(DetailsWidthPrefsKey, detailsWidth); + current.Use(); + } + break; + } + } + + private void OpenOwnersConfig() + { + if (data == null || string.IsNullOrWhiteSpace(data.OwnersConfigPath)) + { + return; + } + + UnityEngine.Object asset = AssetDatabase.LoadAssetAtPath(data.OwnersConfigPath); + if (asset != null) + { + Selection.activeObject = asset; + EditorGUIUtility.PingObject(asset); + } + } + + private int GetOwnerPopupIndex(string[] presetOptions) + { + if (editOwnerIsCustom) + { + return Array.IndexOf(presetOptions, CustomOwnerOption); + } + + int index = Array.FindIndex(presetOptions, owner => string.Equals(owner, editOwner, StringComparison.OrdinalIgnoreCase)); + return index >= 0 ? index : Array.IndexOf(presetOptions, CustomOwnerOption); + } + + private string GetFormattedTime(TaskRecord task) + { + return task == null ? "-" : TaskBoardService.FormatExecutionTimeForDisplay(task.ExecutionTime); + } + + private void SyncSelections(HashSet selectedValues, string[] availableOptions) + { + var availableSet = new HashSet(availableOptions ?? Array.Empty(), StringComparer.OrdinalIgnoreCase); + selectedValues.RemoveWhere(value => !availableSet.Contains(value)); + if (selectedValues.Count == 0) + { + SetAll(selectedValues, availableOptions, true); + } + } + + private void SetAll(HashSet selectedValues, string[] values, bool enabled) + { + selectedValues.Clear(); + if (!enabled || values == null) + { + return; + } + + foreach (string value in values) + { + selectedValues.Add(value); + } } private static bool ContainsIgnoreCase(string source, string value) @@ -631,6 +1064,20 @@ namespace Project.Tasks.Editor return !string.IsNullOrEmpty(source) && source.IndexOf(value, StringComparison.OrdinalIgnoreCase) >= 0; } + private void ResetDeleteConfirmationIfExpired() + { + if (!string.IsNullOrEmpty(pendingDeleteTaskId) && EditorApplication.timeSinceStartup > pendingDeleteUntil) + { + ResetDeleteConfirmation(); + } + } + + private void ResetDeleteConfirmation() + { + pendingDeleteTaskId = null; + pendingDeleteUntil = 0d; + } + private static string SafeValue(string value) { return string.IsNullOrEmpty(value) ? "-" : value; @@ -638,17 +1085,17 @@ namespace Project.Tasks.Editor private static Color GetStatusColor(string status) { - switch (status) + switch (TaskBoardService.NormalizeStatus(status)) { - case "proposal": + case "BackLog": return new Color(0.94f, 0.94f, 0.94f); - case "ready": + case "ToDo": return new Color(0.85f, 0.96f, 0.85f); - case "in_progress": + case "InProgress": return new Color(0.84f, 0.91f, 1f); - case "blocked": + case "Review": return new Color(1f, 0.9f, 0.82f); - case "done": + case "Done": return new Color(0.88f, 0.88f, 0.88f); default: return Color.white; diff --git a/docs/tasks/Index.md b/docs/tasks/Index.md index cec6ca7c..6be931da 100644 --- a/docs/tasks/Index.md +++ b/docs/tasks/Index.md @@ -2,7 +2,7 @@ ## Purpose -Эта папка хранит один файл на каждую отложенную или асинхронную единицу работы и единый реестр статусов, чтобы контекст реализации не терялся между чатами. +Эта папка хранит единый реестр статусов и шаблон, а сами task-файлы лежат в `docs/tasks/items`, чтобы корень `docs/tasks` не превращался в свалку файлов. Файлы задач должны описывать работу достаточно ясно, чтобы будущий человек или AI-агент мог продолжить ее без восстановления исходного замысла по истории переписки. @@ -10,7 +10,7 @@ - используйте `docs/tasks/_template.md` для каждой новой задачи - храните одну задачу в одном файле -- храните все task-файлы плоско в `docs/tasks`, без подпапок по статусам +- храните task-файлы в `docs/tasks/items`, без подпапок по статусам - не переименовывайте и не перемещайте файл задачи при смене статуса - статус задачи считается каноническим по записи в этом индексе - предпочитайте ссылки на канонические документы вместо копирования больших фоновых разделов @@ -24,22 +24,25 @@ ## Supporting Docs - шаблон задачи: `docs/tasks/_template.md` +- task-файлы: `docs/tasks/items/*.md` + +Все отдельные task-файлы храните в `docs/tasks/items/`. ## Statuses -- `proposal` - идея существует, но объем или подход еще не готовы к исполнению -- `ready` - задачу можно брать в работу сейчас -- `in_progress` - по задаче сейчас идет активная работа -- `blocked` - задача ждет решения, зависимости или внешней предпосылки -- `done` - работа завершена; оставьте короткую заметку по итогу и позже при необходимости переместите или переименуйте файл +- `BackLog` - идея или задача существует, но еще не готова к активному исполнению +- `ToDo` - задачу можно брать в работу сейчас +- `InProgress` - по задаче сейчас идет активная работа +- `Review` - задача ждет проверки, решения или следующего подтверждающего шага +- `Done` - работа завершена; оставьте короткую заметку по итогу и позже при необходимости переместите или переименуйте файл ## Task Registry -| ID | Status | Priority | Area | Execution Time | File | Summary | -| --- | --- | --- | --- | --- | --- | --- | -| TASK-0001 | done | Medium | docs | 1d | `docs/tasks/TASK-0001-define-docs-structure-and-migration-plan.md` | Определена целевая структура документации, карта миграции и последовательность работ для переноса docs. | -| TASK-0002 | done | Medium | docs | 1d6h | `docs/tasks/TASK-0002-execute-docs-structure-migration.md` | Выполнен перенос дерева docs в разделы current, runbooks, history, process и tasks, а также обновлены пути входа в документацию. | -| TASK-0003 | in_progress | High | ci_cd | 2d | `docs/tasks/TASK-0003-stabilize-ci-cd-and-validate-pipeline.md` | Требуется итеративно стабилизировать текущий CI/CD путь на GitHub Actions и довести его до подтвержденно рабочего состояния. | -| TASK-0004 | ready | Medium | product | 1d | `docs/tasks/TASK-0004-define-directories-feature-and-implementation-decision.md` | Нужно согласовать и зафиксировать модель фичи directories, чтобы реализация не пошла в неверном направлении. | -| TASK-0005 | blocked | Medium | product | 2d | `docs/tasks/TASK-0005-implement-directories-and-folder-navigation.md` | Реализацию directories нельзя начинать, пока `TASK-0004` не зафиксирует согласованную модель папок и границы выполнения. | -| TASK-0006 | ready | Low | docs | 1d | `docs/tasks/TASK-0006-reposition-readme-as-project-brief.md` | Нужно переписать `README`, чтобы он начинался с идентичности проекта, стека и верхнеуровневого онбординга. | +| ID | Status | Priority | Area | Owner | Execution Time | File | Summary | +| --- | --- | --- | --- | --- | --- | --- | --- | +| TASK-0001 | Done | Medium | docs | unassigned | 1d | `docs/tasks/items/TASK-0001-define-docs-structure-and-migration-plan.md` | Определена целевая структура документации, карта миграции и последовательность работ для переноса docs. | +| TASK-0002 | Done | Highest | docs | unassigned | 1d6h | `docs/tasks/items/TASK-0002-execute-docs-structure-migration.md` | Выполнен перенос дерева docs в разделы current, runbooks, history, process и tasks, а также обновлены пути входа в документацию. | +| TASK-0003 | InProgress | High | ci_cd | unassigned | 2d | `docs/tasks/items/TASK-0003-stabilize-ci-cd-and-validate-pipeline.md` | Требуется итеративно стабилизировать текущий CI/CD путь на GitHub Actions и довести его до подтвержденно рабочего состояния. | +| TASK-0004 | BackLog | Medium | product | unassigned | 1d | `docs/tasks/items/TASK-0004-define-directories-feature-and-implementation-decision.md` | Нужно согласовать и зафиксировать модель фичи directories, чтобы реализация не пошла в неверном направлении. | +| TASK-0005 | Review | Medium | product | unassigned | 2d | `docs/tasks/items/TASK-0005-implement-directories-and-folder-navigation.md` | Реализацию directories нельзя начинать, пока `TASK-0004` не зафиксирует согласованную модель папок и границы выполнения. | +| TASK-0006 | ToDo | Low | docs | unassigned | 1d | `docs/tasks/items/TASK-0006-reposition-readme-as-project-brief.md` | Нужно переписать `README`, чтобы он начинался с идентичности проекта, стека и верхнеуровневого онбординга. | diff --git a/docs/tasks/_template.md b/docs/tasks/_template.md index b020a816..3e34e9de 100644 --- a/docs/tasks/_template.md +++ b/docs/tasks/_template.md @@ -1,6 +1,7 @@ --- id: TASK-XXXX title: Короткий заголовок +summary: Короткое описание задачи priority: Medium area: docs owner: unassigned @@ -20,11 +21,11 @@ related_files: [] Допустимые значения статуса: -- `proposal` -- `ready` -- `in_progress` -- `blocked` -- `done` +- `BackLog` +- `ToDo` +- `InProgress` +- `Review` +- `Done` ## Why @@ -70,7 +71,7 @@ related_files: [] - предпочитайте наименьшее безопасное изменение, которое оставляет после себя более понятную документацию и подтверждение проверки - указывайте `execution_time` в формате Jira, например `1d6h30m`, и только с шагом в 30 минут - используйте приоритеты `Lowest`, `Low`, `Medium`, `High`, `Highest` -- не переименовывайте и не перемещайте task-файл при смене статуса; обновляйте запись в `docs/tasks/Index.md` +- храните task-файл в `docs/tasks/items/` и не переименовывайте его при смене статуса; обновляйте запись в `docs/tasks/Index.md` ## If You Find Drift