# All files / src/components/lists listHelpers.tsx

87.5% Statements 56/64
75% Branches 18/24
72.72% Functions 8/11
89.47% Lines 51/57

Press n or j to go to the next uncovered block, b, p or k for the previous block.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145

    75x

    75x
    75x
    75x
    6x
    6x
    6x

    6x

    75x

    296x
    221x

    75x
    75x
    75x
    75x
    81x
    14x

    81x

    81x

    75x
    69x

    75x

    212x
    153x
    153x
    212x
    2x

    210x

    153x
    212x

    153x

    90x
    90x

    101x

    101x
    101x
    101x
    11x

    90x

    90x
    90x
    90x
    540x
    540x
    540x
    90x
    90x
    90x

    90x
    90x

    90x

     /**
     * Shared helpers for sidebar list components (WorkspaceList, TaskList).
     *
     * @module
     */

    import type { JSX, ReactNode } from "react";
    import type { TaskData } from "../../hooks/types.js";
    import type { MatchIndex } from "@grackle-ai/common";
    import { SIDEBAR_STATUS_ORDER, getStatusStyle } from "../../utils/taskStatus.js";

    // ---------------------------------------------------------------------------
    // Highlight helpers
    // ---------------------------------------------------------------------------

    /** Merge overlapping or adjacent [start, end] ranges into non-overlapping ranges. */
    export function mergeRanges(ranges: readonly MatchIndex[]): MatchIndex[] {
      Iif (ranges.length === 0) {
        return [];
      }
      const sorted = [...ranges].sort((a, b) => a[0] - b[0]);
      const merged: [number, number][] = [[sorted[0][0], sorted[0][1]]];
      for (let i = 1; i < sorted.length; i++) {
        const prev = merged[merged.length - 1];
        const [start, end] = sorted[i];
        Iif (start <= prev[1] + 1) {
          prev[1] = Math.max(prev[1], end);
        } else {
          merged.push([start, end]);
        }
      }
      return merged;
    }

    /** Render text with highlighted match ranges. Unmatched portions are plain, matched portions are bold. */
    export function HighlightedText({
      text,
      indices,
      highlightClass,
    }: {
      text: string;
      indices?: readonly MatchIndex[];
      highlightClass?: string;
    }): JSX.Element {
      if (!indices || indices.length === 0) {
        return <>{text}</>;
      }
      const merged = mergeRanges(indices);
      const parts: JSX.Element[] = [];
      let cursor = 0;
      for (const [start, end] of merged) {
        if (start > cursor) {
          parts.push(<span key={`p${cursor}`}>{text.slice(cursor, start)}</span>);
        }
        parts.push(
          <mark key={`m${start}`} className={highlightClass}>
            {text.slice(start, end + 1)}
          </mark>,
        );
        cursor = end + 1;
      }
      if (cursor < text.length) {
        parts.push(<span key={`p${cursor}`}>{text.slice(cursor)}</span>);
      }
      return <>{parts}</>;
    }

    // ---------------------------------------------------------------------------
    // Task tree
    // ---------------------------------------------------------------------------

    /** A task node with children for recursive tree rendering. */
    export interface TaskNode extends TaskData {
      children: TaskNode[];
    }

    /** Assemble flat TaskData[] into a tree. */
    export function buildTaskTree(taskList: TaskData[]): TaskNode[] {
      const byId = new Map<string, TaskNode>(taskList.map((t) => [t.id, { ...t, children: [] }]));
      const roots: TaskNode[] = [];
      for (const node of byId.values()) {
        if (node.parentTaskId && byId.has(node.parentTaskId)) {
          byId.get(node.parentTaskId)!.children.push(node);
        } else {
          roots.push(node);
        }
      }
      for (const node of byId.values()) {
        node.children.sort((a, b) => a.sortOrder - b.sortOrder);
      }
      return roots.sort((a, b) => a.sortOrder - b.sortOrder);
    }

    // ---------------------------------------------------------------------------
    // Status grouping
    // ---------------------------------------------------------------------------

    /** A group of tasks sharing the same status. */
    export interface StatusGroup {
      status: string;
      label: string;
      style: { color: string; icon: ReactNode };
      tasks: TaskData[];
    }

    /** Group a flat list of tasks by status, ordered by urgency. Blocked tasks are separated into their own group. */
    export function groupTasksByStatus(
      taskList: TaskData[],
      taskStatusById: Map<string, string>,
    ): StatusGroup[] {
      const byStatus = new Map<string, TaskData[]>();
      for (const task of taskList) {
        const isBlocked =
          task.dependsOn.length > 0 &&
          task.dependsOn.some((depId) => taskStatusById.get(depId) !== "complete");
        const groupKey = isBlocked ? "blocked" : task.status;
        const list = byStatus.get(groupKey);
        if (list) {
          list.push(task);
        } else {
          byStatus.set(groupKey, [task]);
        }
      }

      const groups: StatusGroup[] = [];
      const seen = new Set<string>();
      for (const status of SIDEBAR_STATUS_ORDER) {
        seen.add(status);
        const tasks = byStatus.get(status);
        if (tasks && tasks.length > 0) {
          tasks.sort((a, b) => a.sortOrder - b.sortOrder);
          const style = getStatusStyle(status);
          groups.push({ status, label: style.label, style, tasks });
        }
      }
      for (const [status, tasks] of byStatus) {
        Iif (!seen.has(status) && tasks.length > 0) {
          tasks.sort((a, b) => a.sortOrder - b.sortOrder);
          const style = getStatusStyle(status);
          groups.push({ status, label: style.label, style, tasks });
        }
      }
      return groups;
    }