schub 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +36665 -0
- package/package.json +32 -0
- package/src/App.tsx +103 -0
- package/src/changes.ts +237 -0
- package/src/components/PlanView.tsx +91 -0
- package/src/components/StatusView.tsx +217 -0
- package/src/components/statusColor.ts +1 -0
- package/src/features/tasks/constants.ts +30 -0
- package/src/features/tasks/create.ts +122 -0
- package/src/features/tasks/filesystem.ts +178 -0
- package/src/features/tasks/graph.ts +87 -0
- package/src/features/tasks/index.ts +12 -0
- package/src/features/tasks/sorting.ts +14 -0
- package/src/index.ts +458 -0
- package/src/project.ts +96 -0
- package/src/tasks.ts +15 -0
- package/src/terminal.ts +16 -0
- package/templates/create-proposal/adr-template.md +51 -0
- package/templates/create-proposal/proposal-template.md +51 -0
- package/templates/create-tasks/task-template.md +33 -0
- package/templates/review-proposal/q&a-template.md +13 -0
- package/templates/review-proposal/review-me-template.md +18 -0
- package/templates/setup-project/project-overview-template.md +35 -0
- package/templates/setup-project/project-setup-template.md +61 -0
- package/templates/setup-project/project-wow-template.md +130 -0
- package/templates/setup-project/review-me-template.md +18 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const TASK_STATUSES = ["backlog", "ready", "wip", "blocked", "done", "archived"] as const;
|
|
2
|
+
|
|
3
|
+
export type TaskStatus = (typeof TASK_STATUSES)[number];
|
|
4
|
+
|
|
5
|
+
export type TaskInfo = {
|
|
6
|
+
id: string;
|
|
7
|
+
title: string;
|
|
8
|
+
status: TaskStatus;
|
|
9
|
+
path: string;
|
|
10
|
+
changeId?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type TaskDependency = TaskInfo & {
|
|
14
|
+
dependsOn: string[];
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type TaskGraphNode = TaskDependency & {
|
|
18
|
+
dependencyIds: string[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type TaskGraph = {
|
|
22
|
+
roots: TaskGraphNode[];
|
|
23
|
+
childrenById: Map<string, TaskGraphNode[]>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type TaskGraphLine = {
|
|
27
|
+
text: string;
|
|
28
|
+
status: string;
|
|
29
|
+
isDuplicate: boolean;
|
|
30
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const TASK_TEMPLATE_PATH = fileURLToPath(new URL("../../../templates/create-tasks/task-template.md", import.meta.url));
|
|
6
|
+
|
|
7
|
+
const readTaskTemplate = () => {
|
|
8
|
+
try {
|
|
9
|
+
return readFileSync(TASK_TEMPLATE_PATH, "utf8");
|
|
10
|
+
} catch {
|
|
11
|
+
throw new Error(`[ERROR] Template not found: ${TASK_TEMPLATE_PATH}`);
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const createTask = (
|
|
16
|
+
schubDir: string,
|
|
17
|
+
options: { changeId: string; status?: string; titles: string[]; overwrite?: boolean },
|
|
18
|
+
) => {
|
|
19
|
+
const changeId = options.changeId.trim();
|
|
20
|
+
const status = (options.status || "backlog").trim();
|
|
21
|
+
const titles = options.titles.map((t) => t.trim()).filter(Boolean);
|
|
22
|
+
|
|
23
|
+
// Validate change ID format (simple check)
|
|
24
|
+
if (!/^(?:[Cc]\d{3}_)?[a-z0-9]+(?:-[a-z0-9]+)*$/.test(changeId)) {
|
|
25
|
+
throw new Error(`Invalid change-id '${changeId}'. Use kebab-case or a C-prefixed id (e.g., C001_add-user-auth).`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Normalize change ID (ensure C prefix if it matches pattern)
|
|
29
|
+
let normalizedChangeId = changeId;
|
|
30
|
+
const match = changeId.match(/^([Cc])(\d{3})_(.+)$/);
|
|
31
|
+
if (match) {
|
|
32
|
+
normalizedChangeId = `C${match[2]}_${match[3]}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Validate status
|
|
36
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(status)) {
|
|
37
|
+
throw new Error(`Invalid status '${status}'. Use kebab-case (lowercase letters/digits and hyphens).`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!titles.length) {
|
|
41
|
+
throw new Error("Provide at least one --title.");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Validate change proposal
|
|
45
|
+
const changeDir = join(schubDir, "changes", normalizedChangeId);
|
|
46
|
+
const proposalPath = join(changeDir, "proposal.md");
|
|
47
|
+
|
|
48
|
+
if (!existsSync(proposalPath)) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`Required change files missing:\n - ${proposalPath}\nCreate the change proposal before scaffolding tasks.`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const content = readFileSync(proposalPath, "utf8");
|
|
55
|
+
const statusMatch = content.match(/^\*\*Status\*\*:\s*(.+)$/im);
|
|
56
|
+
const proposalStatus = statusMatch?.[1]?.trim();
|
|
57
|
+
|
|
58
|
+
if (!proposalStatus) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Proposal status not found in ${proposalPath}.\nAdd a '**Status**: Accepted' line before scaffolding tasks.`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
if (proposalStatus.toLowerCase() !== "accepted") {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Proposal status is '${proposalStatus}' in ${proposalPath}.\nMark the proposal as Accepted before scaffolding tasks.`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Find next task number
|
|
70
|
+
const tasksRoot = join(schubDir, "tasks");
|
|
71
|
+
const existingNumbers = new Set<number>();
|
|
72
|
+
|
|
73
|
+
if (existsSync(tasksRoot)) {
|
|
74
|
+
const scan = (dir: string) => {
|
|
75
|
+
if (!existsSync(dir)) return;
|
|
76
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
77
|
+
for (const entry of entries) {
|
|
78
|
+
if (entry.isDirectory()) {
|
|
79
|
+
scan(join(dir, entry.name));
|
|
80
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
81
|
+
const m = entry.name.match(/(?:^|-)T(\d{3})(?:_[^.]+)?\.md$/);
|
|
82
|
+
if (m) existingNumbers.add(Number.parseInt(m[1], 10));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
scan(tasksRoot);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let nextNumber = existingNumbers.size > 0 ? Math.max(...existingNumbers) + 1 : 1;
|
|
90
|
+
const statusDir = join(tasksRoot, status);
|
|
91
|
+
mkdirSync(statusDir, { recursive: true });
|
|
92
|
+
const template = readTaskTemplate();
|
|
93
|
+
|
|
94
|
+
const createdPaths: string[] = [];
|
|
95
|
+
|
|
96
|
+
for (const title of titles) {
|
|
97
|
+
const taskId = `T${nextNumber.toString().padStart(3, "0")}`;
|
|
98
|
+
let slug = title
|
|
99
|
+
.toLowerCase()
|
|
100
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
101
|
+
.replace(/^-|-$/g, "");
|
|
102
|
+
if (!slug) slug = "task";
|
|
103
|
+
|
|
104
|
+
const filename = `${taskId}_${slug}.md`;
|
|
105
|
+
const taskPath = join(statusDir, filename);
|
|
106
|
+
|
|
107
|
+
const rendered = template
|
|
108
|
+
.replace("{{TASK_ID}}", taskId)
|
|
109
|
+
.replace("{{TASK_TITLE}}", title)
|
|
110
|
+
.replace("{{CHANGE_ID}}", normalizedChangeId);
|
|
111
|
+
|
|
112
|
+
if (existsSync(taskPath) && !options.overwrite) {
|
|
113
|
+
throw new Error(`Refusing to overwrite existing file: ${taskPath}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
writeFileSync(taskPath, rendered, "utf8");
|
|
117
|
+
createdPaths.push(taskPath);
|
|
118
|
+
nextNumber++;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return createdPaths;
|
|
122
|
+
};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
3
|
+
import { TASK_STATUSES, type TaskDependency, type TaskInfo, type TaskStatus } from "./constants";
|
|
4
|
+
import { compareTaskIds, compareTasks } from "./sorting";
|
|
5
|
+
|
|
6
|
+
const isDirectory = (path: string): boolean => {
|
|
7
|
+
try {
|
|
8
|
+
return statSync(path).isDirectory();
|
|
9
|
+
} catch {
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const findSchubRoot = (startDir: string = process.cwd()): string | null => {
|
|
15
|
+
let current = resolve(startDir);
|
|
16
|
+
|
|
17
|
+
while (true) {
|
|
18
|
+
const candidate = join(current, ".schub");
|
|
19
|
+
if (existsSync(candidate) && isDirectory(candidate)) {
|
|
20
|
+
return candidate;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const parent = dirname(current);
|
|
24
|
+
if (parent === current) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
current = parent;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const parseTaskFilename = (fileName: string): { id: string; title: string } | null => {
|
|
32
|
+
if (!fileName.endsWith(".md")) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const baseName = fileName.slice(0, -3);
|
|
37
|
+
const underscoreIndex = baseName.indexOf("_");
|
|
38
|
+
if (underscoreIndex <= 0) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const id = baseName.slice(0, underscoreIndex);
|
|
43
|
+
const titleSlug = baseName.slice(underscoreIndex + 1);
|
|
44
|
+
return { id, title: titleSlug.replace(/-/g, " ") };
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type ParsedTaskFile = {
|
|
48
|
+
id: string;
|
|
49
|
+
title: string;
|
|
50
|
+
dependsOn: string[];
|
|
51
|
+
changeId?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const parseTaskFile = (filePath: string, fallback: { id: string; title: string }): ParsedTaskFile => {
|
|
55
|
+
let content = "";
|
|
56
|
+
try {
|
|
57
|
+
content = readFileSync(filePath, "utf8");
|
|
58
|
+
} catch {
|
|
59
|
+
return { ...fallback, dependsOn: [] };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const headerMatch = content.match(/^#\s*Task:\s*(\S+)\s+(.+)$/m);
|
|
63
|
+
const id = headerMatch?.[1] ?? fallback.id;
|
|
64
|
+
const title = headerMatch?.[2]?.trim() ?? fallback.title;
|
|
65
|
+
|
|
66
|
+
const changeIdMatch = content.match(/^\*\*Change ID\*\*:\s*\[?`?([^\]`]+)`?]?/m);
|
|
67
|
+
const changeId = changeIdMatch?.[1]?.trim();
|
|
68
|
+
|
|
69
|
+
const dependsMatch = content.match(/^\*\*Depends on\*\*:\s*(.+)$/m);
|
|
70
|
+
let dependsOn: string[] = [];
|
|
71
|
+
if (dependsMatch) {
|
|
72
|
+
const raw = dependsMatch[1].trim();
|
|
73
|
+
if (!/^none$/i.test(raw)) {
|
|
74
|
+
const matches = raw.match(/\bT\d+\b/g);
|
|
75
|
+
if (matches) {
|
|
76
|
+
dependsOn = Array.from(new Set(matches));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { id, title, dependsOn, changeId };
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export const listTasks = (schubDir: string, statuses: readonly TaskStatus[] = TASK_STATUSES): TaskInfo[] => {
|
|
85
|
+
const tasksRoot = join(schubDir, "tasks");
|
|
86
|
+
if (!existsSync(tasksRoot) || !isDirectory(tasksRoot)) {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const allowed = new Set(statuses);
|
|
91
|
+
const repoRoot = dirname(schubDir);
|
|
92
|
+
const tasks: TaskInfo[] = [];
|
|
93
|
+
|
|
94
|
+
for (const status of TASK_STATUSES) {
|
|
95
|
+
if (!allowed.has(status)) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const statusDir = join(tasksRoot, status);
|
|
100
|
+
if (!existsSync(statusDir) || !isDirectory(statusDir)) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const entries = readdirSync(statusDir, { withFileTypes: true });
|
|
105
|
+
for (const entry of entries) {
|
|
106
|
+
if (!entry.isFile()) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const parsed = parseTaskFilename(entry.name);
|
|
111
|
+
if (!parsed) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const filePath = join(statusDir, entry.name);
|
|
116
|
+
tasks.push({
|
|
117
|
+
id: parsed.id,
|
|
118
|
+
title: parsed.title,
|
|
119
|
+
status,
|
|
120
|
+
path: relative(repoRoot, filePath),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return tasks.sort(compareTasks);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export const loadTaskDependencies = (
|
|
129
|
+
schubDir: string,
|
|
130
|
+
statuses: readonly TaskStatus[] = TASK_STATUSES,
|
|
131
|
+
): TaskDependency[] => {
|
|
132
|
+
const tasksRoot = join(schubDir, "tasks");
|
|
133
|
+
if (!existsSync(tasksRoot) || !isDirectory(tasksRoot)) {
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const allowed = new Set(statuses);
|
|
138
|
+
const repoRoot = dirname(schubDir);
|
|
139
|
+
const tasks: TaskDependency[] = [];
|
|
140
|
+
|
|
141
|
+
for (const status of TASK_STATUSES) {
|
|
142
|
+
if (!allowed.has(status)) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const statusDir = join(tasksRoot, status);
|
|
147
|
+
if (!existsSync(statusDir) || !isDirectory(statusDir)) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const entries = readdirSync(statusDir, { withFileTypes: true });
|
|
152
|
+
for (const entry of entries) {
|
|
153
|
+
if (!entry.isFile()) {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const parsed = parseTaskFilename(entry.name);
|
|
158
|
+
if (!parsed) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const filePath = join(statusDir, entry.name);
|
|
163
|
+
const parsedFile = parseTaskFile(filePath, parsed);
|
|
164
|
+
const dependsOn = parsedFile.dependsOn.sort(compareTaskIds);
|
|
165
|
+
|
|
166
|
+
tasks.push({
|
|
167
|
+
id: parsedFile.id,
|
|
168
|
+
title: parsedFile.title,
|
|
169
|
+
status,
|
|
170
|
+
path: relative(repoRoot, filePath),
|
|
171
|
+
dependsOn,
|
|
172
|
+
changeId: parsedFile.changeId,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return tasks.sort(compareTasks);
|
|
178
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { TaskDependency, TaskGraph, TaskGraphLine, TaskGraphNode } from "./constants";
|
|
2
|
+
import { compareTaskIds, compareTasks } from "./sorting";
|
|
3
|
+
|
|
4
|
+
export const buildTaskGraph = (tasks: TaskDependency[]): TaskGraph => {
|
|
5
|
+
const tasksById = new Map(tasks.map((task) => [task.id, task]));
|
|
6
|
+
const nodes = new Map<string, TaskGraphNode>();
|
|
7
|
+
|
|
8
|
+
for (const task of tasks) {
|
|
9
|
+
const uniqueDependencies = Array.from(new Set(task.dependsOn)).sort(compareTaskIds);
|
|
10
|
+
const dependencyIds = uniqueDependencies.filter((dependency) => tasksById.has(dependency));
|
|
11
|
+
|
|
12
|
+
nodes.set(task.id, {
|
|
13
|
+
...task,
|
|
14
|
+
dependencyIds,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const childrenById = new Map<string, TaskGraphNode[]>();
|
|
19
|
+
for (const node of nodes.values()) {
|
|
20
|
+
for (const dependencyId of node.dependencyIds) {
|
|
21
|
+
const siblings = childrenById.get(dependencyId) ?? [];
|
|
22
|
+
siblings.push(node);
|
|
23
|
+
childrenById.set(dependencyId, siblings);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
for (const [id, children] of childrenById) {
|
|
28
|
+
childrenById.set(id, children.sort(compareTasks));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const roots = Array.from(nodes.values())
|
|
32
|
+
.filter((node) => node.dependencyIds.length === 0)
|
|
33
|
+
.sort(compareTasks);
|
|
34
|
+
|
|
35
|
+
return { roots, childrenById };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const trimTaskTitle = (title: string, maxLength: number = 40): string => {
|
|
39
|
+
const trimmed = title.trim();
|
|
40
|
+
if (trimmed.length <= maxLength) {
|
|
41
|
+
return trimmed;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (maxLength <= 1) {
|
|
45
|
+
return "…";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return `${trimmed.slice(0, maxLength - 1).trimEnd()}…`;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const renderTaskGraphLines = (graph: TaskGraph): TaskGraphLine[] => {
|
|
52
|
+
const lines: TaskGraphLine[] = [];
|
|
53
|
+
const renderedIds = new Set<string>();
|
|
54
|
+
|
|
55
|
+
const renderNode = (node: TaskGraphNode, prefix: string, isLast: boolean, isRoot: boolean) => {
|
|
56
|
+
const connector = isRoot ? "" : isLast ? "└─ " : "├─ ";
|
|
57
|
+
|
|
58
|
+
if (renderedIds.has(node.id)) {
|
|
59
|
+
lines.push({
|
|
60
|
+
text: `${prefix}${connector}${node.id} ↩`,
|
|
61
|
+
status: node.status,
|
|
62
|
+
isDuplicate: true,
|
|
63
|
+
});
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
renderedIds.add(node.id);
|
|
68
|
+
const title = trimTaskTitle(node.title);
|
|
69
|
+
lines.push({
|
|
70
|
+
text: `${prefix}${connector}${node.id} ${title}`,
|
|
71
|
+
status: node.status,
|
|
72
|
+
isDuplicate: false,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const children = graph.childrenById.get(node.id) ?? [];
|
|
76
|
+
const nextPrefix = isRoot ? `${prefix} ` : `${prefix}${isLast ? " " : "│ "}`;
|
|
77
|
+
children.forEach((child, index) => {
|
|
78
|
+
renderNode(child, nextPrefix, index === children.length - 1, false);
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
graph.roots.forEach((root, index) => {
|
|
83
|
+
renderNode(root, "", index === graph.roots.length - 1, true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return lines;
|
|
87
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export {
|
|
2
|
+
TASK_STATUSES,
|
|
3
|
+
type TaskDependency,
|
|
4
|
+
type TaskGraph,
|
|
5
|
+
type TaskGraphLine,
|
|
6
|
+
type TaskGraphNode,
|
|
7
|
+
type TaskInfo,
|
|
8
|
+
type TaskStatus,
|
|
9
|
+
} from "./constants";
|
|
10
|
+
export { createTask } from "./create";
|
|
11
|
+
export { findSchubRoot, listTasks, loadTaskDependencies } from "./filesystem";
|
|
12
|
+
export { buildTaskGraph, renderTaskGraphLines, trimTaskTitle } from "./graph";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const taskNumber = (id: string): number => {
|
|
2
|
+
const match = id.match(/\d+/);
|
|
3
|
+
return match ? Number(match[0]) : Number.POSITIVE_INFINITY;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export const compareTaskIds = (left: string, right: string): number => {
|
|
7
|
+
const numberDiff = taskNumber(left) - taskNumber(right);
|
|
8
|
+
if (numberDiff !== 0) {
|
|
9
|
+
return numberDiff;
|
|
10
|
+
}
|
|
11
|
+
return left.localeCompare(right);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const compareTasks = (left: { id: string }, right: { id: string }): number => compareTaskIds(left.id, right.id);
|