@critical-path/core 0.20.1 → 0.21.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/CHANGELOG.md +20 -0
- package/README.md +41 -0
- package/dist/domain/entities.d.ts +4 -0
- package/dist/domain/entities.d.ts.map +1 -1
- package/dist/domain/entities.js +56 -0
- package/dist/domain/entities.js.map +1 -1
- package/dist/domain/metrics.d.ts.map +1 -1
- package/dist/domain/metrics.js +2 -1
- package/dist/domain/metrics.js.map +1 -1
- package/dist/domain/task-duration.test.js +161 -0
- package/dist/domain/task-duration.test.js.map +1 -1
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +113 -21
- package/dist/engine/index.js.map +1 -1
- package/dist/status.test.js +97 -1
- package/dist/status.test.js.map +1 -1
- package/dist/store/sqlite.d.ts.map +1 -1
- package/dist/store/sqlite.js +22 -7
- package/dist/store/sqlite.js.map +1 -1
- package/dist/types/index.d.ts +7 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/fractional-index.d.ts +50 -0
- package/dist/utils/fractional-index.d.ts.map +1 -0
- package/dist/utils/fractional-index.js +230 -0
- package/dist/utils/fractional-index.js.map +1 -0
- package/dist/utils/fractional-index.test.d.ts +2 -0
- package/dist/utils/fractional-index.test.d.ts.map +1 -0
- package/dist/utils/fractional-index.test.js +151 -0
- package/dist/utils/fractional-index.test.js.map +1 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/key.d.ts +5 -0
- package/dist/utils/key.d.ts.map +1 -1
- package/dist/utils/key.js +10 -0
- package/dist/utils/key.js.map +1 -1
- package/dist/utils/key.test.js +18 -1
- package/dist/utils/key.test.js.map +1 -1
- package/dist/utils/mentions.d.ts +13 -5
- package/dist/utils/mentions.d.ts.map +1 -1
- package/dist/utils/mentions.js +52 -16
- package/dist/utils/mentions.js.map +1 -1
- package/dist/utils/mentions.test.js +32 -1
- package/dist/utils/mentions.test.js.map +1 -1
- package/dist/utils/status.d.ts +80 -1
- package/dist/utils/status.d.ts.map +1 -1
- package/dist/utils/status.js +194 -0
- package/dist/utils/status.js.map +1 -1
- package/package.json +1 -1
- package/src/domain/entities.ts +56 -0
- package/src/domain/metrics.ts +2 -1
- package/src/domain/task-duration.test.ts +184 -0
- package/src/engine/index.ts +121 -28
- package/src/status.test.ts +130 -2
- package/src/store/sqlite.ts +28 -7
- package/src/types/index.ts +7 -0
- package/src/utils/fractional-index.test.ts +191 -0
- package/src/utils/fractional-index.ts +260 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/key.test.ts +22 -1
- package/src/utils/key.ts +11 -0
- package/src/utils/mentions.test.ts +37 -1
- package/src/utils/mentions.ts +58 -16
- package/src/utils/status.ts +247 -0
- package/tsconfig.tsbuildinfo +1 -1
package/src/store/sqlite.ts
CHANGED
|
@@ -123,6 +123,7 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
123
123
|
plannedStartDate TEXT,
|
|
124
124
|
actualStartDate TEXT,
|
|
125
125
|
actualEndDate TEXT,
|
|
126
|
+
completedAt TEXT,
|
|
126
127
|
dueDate TEXT,
|
|
127
128
|
estimatedHours REAL,
|
|
128
129
|
loggedHours REAL,
|
|
@@ -133,6 +134,8 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
133
134
|
billableDurationMinutes REAL,
|
|
134
135
|
actualDurationSeconds REAL,
|
|
135
136
|
inProgressSince TEXT,
|
|
137
|
+
blockedDurationSeconds REAL,
|
|
138
|
+
blockedSince TEXT,
|
|
136
139
|
progress REAL,
|
|
137
140
|
isBlocked INTEGER,
|
|
138
141
|
blockedReason TEXT,
|
|
@@ -264,6 +267,12 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
264
267
|
} catch {
|
|
265
268
|
// Column may already exist
|
|
266
269
|
}
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
this.db.exec('ALTER TABLE tasks ADD COLUMN completedAt TEXT');
|
|
273
|
+
} catch {
|
|
274
|
+
// Column may already exist
|
|
275
|
+
}
|
|
267
276
|
}
|
|
268
277
|
|
|
269
278
|
// --- Projects ---
|
|
@@ -660,21 +669,21 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
660
669
|
const stmt = this.db.prepare(`
|
|
661
670
|
INSERT INTO tasks (
|
|
662
671
|
id, projectId, title, description, status, priority, taskType, assigneeId, assignees, reporterId,
|
|
663
|
-
reviewerId, iterationId, teamId, containerId, deliverableId, plannedStartDate, actualStartDate, actualEndDate,
|
|
672
|
+
reviewerId, iterationId, teamId, containerId, deliverableId, plannedStartDate, actualStartDate, actualEndDate, completedAt,
|
|
664
673
|
dueDate, estimatedHours, loggedHours, actualHours, billableHours,
|
|
665
674
|
estimatedDurationMinutes, actualDurationMinutes, billableDurationMinutes,
|
|
666
|
-
actualDurationSeconds, inProgressSince, progress,
|
|
675
|
+
actualDurationSeconds, inProgressSince, blockedDurationSeconds, blockedSince, progress,
|
|
667
676
|
isBlocked, blockedReason,
|
|
668
677
|
tags, customFields, parentId, createdAt, updatedAt
|
|
669
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
678
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
670
679
|
`);
|
|
671
680
|
stmt.run(
|
|
672
681
|
newTask.id,
|
|
673
682
|
newTask.projectId,
|
|
674
683
|
newTask.title,
|
|
675
684
|
newTask.description || null,
|
|
676
|
-
newTask.status,
|
|
677
|
-
newTask.priority,
|
|
685
|
+
newTask.status || 'todo',
|
|
686
|
+
newTask.priority || 'medium',
|
|
678
687
|
newTask.taskType || null,
|
|
679
688
|
newTask.assigneeId || null,
|
|
680
689
|
newTask.assignees ? JSON.stringify(newTask.assignees) : null,
|
|
@@ -687,6 +696,7 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
687
696
|
newTask.plannedStartDate || null,
|
|
688
697
|
newTask.actualStartDate || null,
|
|
689
698
|
newTask.actualEndDate || null,
|
|
699
|
+
newTask.completedAt || null,
|
|
690
700
|
newTask.dueDate || null,
|
|
691
701
|
newTask.estimatedHours ?? null,
|
|
692
702
|
newTask.loggedHours ?? null,
|
|
@@ -697,6 +707,8 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
697
707
|
newTask.billableDurationMinutes ?? null,
|
|
698
708
|
newTask.actualDurationSeconds ?? null,
|
|
699
709
|
newTask.inProgressSince ?? null,
|
|
710
|
+
newTask.blockedDurationSeconds ?? null,
|
|
711
|
+
newTask.blockedSince ?? null,
|
|
700
712
|
newTask.progress ?? null,
|
|
701
713
|
newTask.isBlocked !== undefined ? (newTask.isBlocked ? 1 : 0) : null,
|
|
702
714
|
newTask.blockedReason ?? null,
|
|
@@ -723,10 +735,10 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
723
735
|
UPDATE tasks SET
|
|
724
736
|
projectId = ?, title = ?, description = ?, status = ?, priority = ?, taskType = ?,
|
|
725
737
|
assigneeId = ?, assignees = ?, reporterId = ?, reviewerId = ?, iterationId = ?, teamId = ?, containerId = ?, deliverableId = ?,
|
|
726
|
-
plannedStartDate = ?, actualStartDate = ?, actualEndDate = ?, dueDate = ?,
|
|
738
|
+
plannedStartDate = ?, actualStartDate = ?, actualEndDate = ?, completedAt = ?, dueDate = ?,
|
|
727
739
|
estimatedHours = ?, loggedHours = ?, actualHours = ?, billableHours = ?,
|
|
728
740
|
estimatedDurationMinutes = ?, actualDurationMinutes = ?, billableDurationMinutes = ?,
|
|
729
|
-
actualDurationSeconds = ?, inProgressSince = ?, progress = ?,
|
|
741
|
+
actualDurationSeconds = ?, inProgressSince = ?, blockedDurationSeconds = ?, blockedSince = ?, progress = ?,
|
|
730
742
|
isBlocked = ?, blockedReason = ?,
|
|
731
743
|
tags = ?, customFields = ?, parentId = ?, updatedAt = ?
|
|
732
744
|
WHERE id = ?
|
|
@@ -749,6 +761,7 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
749
761
|
updated.plannedStartDate || null,
|
|
750
762
|
updated.actualStartDate || null,
|
|
751
763
|
updated.actualEndDate || null,
|
|
764
|
+
updated.completedAt || null,
|
|
752
765
|
updated.dueDate || null,
|
|
753
766
|
updated.estimatedHours ?? null,
|
|
754
767
|
updated.loggedHours ?? null,
|
|
@@ -759,6 +772,8 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
759
772
|
updated.billableDurationMinutes ?? null,
|
|
760
773
|
updated.actualDurationSeconds ?? null,
|
|
761
774
|
updated.inProgressSince ?? null,
|
|
775
|
+
updated.blockedDurationSeconds ?? null,
|
|
776
|
+
updated.blockedSince ?? null,
|
|
762
777
|
updated.progress ?? null,
|
|
763
778
|
updated.isBlocked !== undefined ? (updated.isBlocked ? 1 : 0) : null,
|
|
764
779
|
updated.blockedReason ?? null,
|
|
@@ -1204,6 +1219,12 @@ export class SQLiteStore implements StorageAdapter {
|
|
|
1204
1219
|
private mapTask(row: any): Task {
|
|
1205
1220
|
return {
|
|
1206
1221
|
...row,
|
|
1222
|
+
actualDurationSeconds: row.actualDurationSeconds ?? undefined,
|
|
1223
|
+
inProgressSince: row.inProgressSince || undefined,
|
|
1224
|
+
blockedDurationSeconds: row.blockedDurationSeconds ?? undefined,
|
|
1225
|
+
blockedSince: row.blockedSince || undefined,
|
|
1226
|
+
actualEndDate: row.actualEndDate || undefined,
|
|
1227
|
+
completedAt: row.completedAt || undefined,
|
|
1207
1228
|
isBlocked: row.isBlocked !== null && row.isBlocked !== undefined ? Boolean(row.isBlocked) : undefined,
|
|
1208
1229
|
blockedReason: row.blockedReason || undefined,
|
|
1209
1230
|
taskType: row.taskType || undefined,
|
package/src/types/index.ts
CHANGED
|
@@ -205,6 +205,7 @@ export interface Task {
|
|
|
205
205
|
plannedStartDate?: string;
|
|
206
206
|
actualStartDate?: string;
|
|
207
207
|
actualEndDate?: string;
|
|
208
|
+
completedAt?: string;
|
|
208
209
|
dueDate?: string;
|
|
209
210
|
// Duration & Effort (in hours and/or minutes)
|
|
210
211
|
estimatedHours?: number;
|
|
@@ -218,6 +219,10 @@ export interface Task {
|
|
|
218
219
|
actualDurationSeconds?: number;
|
|
219
220
|
/** Transient ISO timestamp marking the start of current 'in_progress' session (null when not in_progress) */
|
|
220
221
|
inProgressSince?: string | null;
|
|
222
|
+
/** Cumulative duration spent in blocked state while in progress (in seconds) */
|
|
223
|
+
blockedDurationSeconds?: number;
|
|
224
|
+
/** Transient ISO timestamp marking when the task became blocked while in progress (null when not blocked or not in_progress) */
|
|
225
|
+
blockedSince?: string | null;
|
|
221
226
|
// Progress (0 to 100 percentage)
|
|
222
227
|
progress?: number;
|
|
223
228
|
isBlocked?: boolean;
|
|
@@ -634,6 +639,8 @@ export interface TaskInferredActuals {
|
|
|
634
639
|
calendarDurationHours?: number;
|
|
635
640
|
/** Cumulative active execution duration in seconds */
|
|
636
641
|
actualDurationSeconds?: number;
|
|
642
|
+
/** Cumulative duration spent in blocked state while in progress (in seconds) */
|
|
643
|
+
blockedDurationSeconds?: number;
|
|
637
644
|
}
|
|
638
645
|
|
|
639
646
|
export interface TaskMetrics {
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
encodeInteger,
|
|
4
|
+
decodeInteger,
|
|
5
|
+
getIntegerPart,
|
|
6
|
+
getInitialKey,
|
|
7
|
+
generateKeyBetween,
|
|
8
|
+
generateNKeysBetween,
|
|
9
|
+
compareOrderIndices
|
|
10
|
+
} from './fractional-index.js';
|
|
11
|
+
|
|
12
|
+
describe('fractional-index', () => {
|
|
13
|
+
describe('encodeInteger and decodeInteger', () => {
|
|
14
|
+
it('encodes and decodes non-negative integers', () => {
|
|
15
|
+
const cases = [0, 1, 2, 9, 10, 61, 62, 100, 1000, 3843, 3844];
|
|
16
|
+
for (const n of cases) {
|
|
17
|
+
const encoded = encodeInteger(n);
|
|
18
|
+
expect(decodeInteger(encoded)).toBe(n);
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('encodes and decodes negative integers', () => {
|
|
23
|
+
const cases = [-1, -2, -10, -62, -100, -1000];
|
|
24
|
+
for (const n of cases) {
|
|
25
|
+
const encoded = encodeInteger(n);
|
|
26
|
+
expect(decodeInteger(encoded)).toBe(n);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('strictly maintains lexicographical sorting across integers', () => {
|
|
31
|
+
const sequence = [-100, -62, -10, -2, -1, 0, 1, 2, 9, 10, 61, 62, 100, 3843, 3844];
|
|
32
|
+
const encoded = sequence.map((n) => encodeInteger(n));
|
|
33
|
+
|
|
34
|
+
for (let i = 0; i < encoded.length - 1; i++) {
|
|
35
|
+
expect(encoded[i] < encoded[i + 1]).toBe(true);
|
|
36
|
+
expect(compareOrderIndices(encoded[i], encoded[i + 1])).toBeLessThan(0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Reversing and sorting with compareOrderIndices or standard sort() restores exact order
|
|
40
|
+
const reversed = [...encoded].reverse();
|
|
41
|
+
expect(reversed.sort(compareOrderIndices)).toEqual(encoded);
|
|
42
|
+
expect([...encoded].reverse().sort()).toEqual(encoded);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('getInitialKey', () => {
|
|
47
|
+
it('generates spaced initial keys', () => {
|
|
48
|
+
const k0 = getInitialKey(0);
|
|
49
|
+
const k1 = getInitialKey(1);
|
|
50
|
+
const k2 = getInitialKey(2);
|
|
51
|
+
|
|
52
|
+
expect(k0).toBe('a0');
|
|
53
|
+
expect(k1).toBe('a2');
|
|
54
|
+
expect(k2).toBe('a4');
|
|
55
|
+
|
|
56
|
+
expect(k0 < k1).toBe(true);
|
|
57
|
+
expect(k1 < k2).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('generateKeyBetween', () => {
|
|
62
|
+
it('returns default initial key when both bounds are null/undefined', () => {
|
|
63
|
+
expect(generateKeyBetween(null, null)).toBe('a0');
|
|
64
|
+
expect(generateKeyBetween(undefined, undefined)).toBe('a0');
|
|
65
|
+
expect(generateKeyBetween('', '')).toBe('a0');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('generates a key before an upper bound', () => {
|
|
69
|
+
const b0 = 'a0';
|
|
70
|
+
const beforeB0 = generateKeyBetween(null, b0);
|
|
71
|
+
expect(beforeB0 < b0).toBe(true);
|
|
72
|
+
|
|
73
|
+
const b1 = 'a1';
|
|
74
|
+
const beforeB1 = generateKeyBetween(null, b1);
|
|
75
|
+
expect(beforeB1 < b1).toBe(true);
|
|
76
|
+
|
|
77
|
+
const bFractional = 'a0V';
|
|
78
|
+
const beforeFractional = generateKeyBetween(null, bFractional);
|
|
79
|
+
expect(beforeFractional < bFractional).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('generates a key after a lower bound', () => {
|
|
83
|
+
const a0 = 'a0';
|
|
84
|
+
const afterA0 = generateKeyBetween(a0, null);
|
|
85
|
+
expect(a0 < afterA0).toBe(true);
|
|
86
|
+
|
|
87
|
+
const aFractional = 'a0V';
|
|
88
|
+
const afterFractional = generateKeyBetween(aFractional, null);
|
|
89
|
+
expect(aFractional < afterFractional).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('generates key between spaced integers', () => {
|
|
93
|
+
const a = 'a0';
|
|
94
|
+
const b = 'a2';
|
|
95
|
+
const mid = generateKeyBetween(a, b);
|
|
96
|
+
|
|
97
|
+
expect(mid).toBe('a1');
|
|
98
|
+
expect(a < mid).toBe(true);
|
|
99
|
+
expect(mid < b).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('generates fractional key between adjacent integers', () => {
|
|
103
|
+
const a = 'a0';
|
|
104
|
+
const b = 'a1';
|
|
105
|
+
const mid = generateKeyBetween(a, b);
|
|
106
|
+
|
|
107
|
+
expect(a < mid).toBe(true);
|
|
108
|
+
expect(mid < b).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('handles nested fractional insertions without degradation', () => {
|
|
112
|
+
let current = 'a0';
|
|
113
|
+
const target = 'a1';
|
|
114
|
+
|
|
115
|
+
// Perform 50 sequential midpoint insertions
|
|
116
|
+
for (let i = 0; i < 50; i++) {
|
|
117
|
+
const next = generateKeyBetween(current, target);
|
|
118
|
+
expect(current < next).toBe(true);
|
|
119
|
+
expect(next < target).toBe(true);
|
|
120
|
+
current = next;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('generateNKeysBetween', () => {
|
|
126
|
+
it('returns empty array for count <= 0', () => {
|
|
127
|
+
expect(generateNKeysBetween('a0', 'a2', 0)).toEqual([]);
|
|
128
|
+
expect(generateNKeysBetween('a0', 'a2', -1)).toEqual([]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('generates 1 key matching generateKeyBetween', () => {
|
|
132
|
+
const keys = generateNKeysBetween('a0', 'a2', 1);
|
|
133
|
+
expect(keys.length).toBe(1);
|
|
134
|
+
expect(keys[0]).toBe('a1');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('generates N keys in strictly ascending order between a and b', () => {
|
|
138
|
+
const count = 5;
|
|
139
|
+
const a = 'a0';
|
|
140
|
+
const b = 'a1';
|
|
141
|
+
const keys = generateNKeysBetween(a, b, count);
|
|
142
|
+
|
|
143
|
+
expect(keys.length).toBe(count);
|
|
144
|
+
|
|
145
|
+
// Check strictly greater than a
|
|
146
|
+
expect(a < keys[0]).toBe(true);
|
|
147
|
+
|
|
148
|
+
// Check strictly increasing
|
|
149
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
150
|
+
expect(keys[i] < keys[i + 1]).toBe(true);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Check strictly less than b
|
|
154
|
+
expect(keys[count - 1] < b).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('generates N keys at swimlane start (a is null)', () => {
|
|
158
|
+
const count = 3;
|
|
159
|
+
const b = 'a0';
|
|
160
|
+
const keys = generateNKeysBetween(null, b, count);
|
|
161
|
+
|
|
162
|
+
expect(keys.length).toBe(count);
|
|
163
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
164
|
+
expect(keys[i] < keys[i + 1]).toBe(true);
|
|
165
|
+
}
|
|
166
|
+
expect(keys[count - 1] < b).toBe(true);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('generates N keys at swimlane end (b is null)', () => {
|
|
170
|
+
const count = 4;
|
|
171
|
+
const a = 'a5';
|
|
172
|
+
const keys = generateNKeysBetween(a, null, count);
|
|
173
|
+
|
|
174
|
+
expect(keys.length).toBe(count);
|
|
175
|
+
expect(a < keys[0]).toBe(true);
|
|
176
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
177
|
+
expect(keys[i] < keys[i + 1]).toBe(true);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('generates N keys in empty swimlane (both null)', () => {
|
|
182
|
+
const count = 6;
|
|
183
|
+
const keys = generateNKeysBetween(null, null, count);
|
|
184
|
+
|
|
185
|
+
expect(keys.length).toBe(count);
|
|
186
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
187
|
+
expect(keys[i] < keys[i + 1]).toBe(true);
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fractional Lexical Indexing Utility
|
|
3
|
+
*
|
|
4
|
+
* Deterministic Base-62 fractional indexing for ordered lists and swimlane task sequences.
|
|
5
|
+
* Produces lexicographically sortable strings (`orderIndex`) that allow infinite insertion
|
|
6
|
+
* between any two items without floating-point precision loss.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const BASE_62 = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Encodes a non-negative integer into Base-62.
|
|
13
|
+
*/
|
|
14
|
+
function toBase62(n: number): string {
|
|
15
|
+
if (n === 0) return '0';
|
|
16
|
+
let s = '';
|
|
17
|
+
let curr = n;
|
|
18
|
+
while (curr > 0) {
|
|
19
|
+
s = BASE_62[curr % 62] + s;
|
|
20
|
+
curr = Math.floor(curr / 62);
|
|
21
|
+
}
|
|
22
|
+
return s;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Decodes a Base-62 string into a non-negative integer.
|
|
27
|
+
*/
|
|
28
|
+
function fromBase62(s: string): number {
|
|
29
|
+
let n = 0;
|
|
30
|
+
for (let i = 0; i < s.length; i++) {
|
|
31
|
+
const idx = BASE_62.indexOf(s[i]);
|
|
32
|
+
if (idx === -1) return 0;
|
|
33
|
+
n = n * 62 + idx;
|
|
34
|
+
}
|
|
35
|
+
return n;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Encodes an integer into a prefix-encoded string such that standard
|
|
40
|
+
* string comparison strictly matches integer comparison.
|
|
41
|
+
*
|
|
42
|
+
* Non-negative integers (0, 1, 2, ...):
|
|
43
|
+
* Prefix 'a'-'z' specifies the digit count (1 to 26 digits).
|
|
44
|
+
* 0 -> "a0", 1 -> "a1", 61 -> "az", 62 -> "b10", ...
|
|
45
|
+
*
|
|
46
|
+
* Negative integers (-1, -2, ...):
|
|
47
|
+
* Prefix 'Z'-'A' specifies inverted digit count with inverted digits.
|
|
48
|
+
* -1 -> "Zz", -2 -> "Zy", ...
|
|
49
|
+
*/
|
|
50
|
+
export function encodeInteger(n: number): string {
|
|
51
|
+
if (n >= 0) {
|
|
52
|
+
const digits = toBase62(n);
|
|
53
|
+
const prefixCode = 97 + digits.length - 1; // 97 is 'a'
|
|
54
|
+
const prefix = String.fromCharCode(Math.min(prefixCode, 122)); // clamp to 'z'
|
|
55
|
+
return prefix + digits;
|
|
56
|
+
} else {
|
|
57
|
+
const pos = -n - 1;
|
|
58
|
+
const digits = toBase62(pos);
|
|
59
|
+
let inverted = '';
|
|
60
|
+
for (let i = 0; i < digits.length; i++) {
|
|
61
|
+
const idx = BASE_62.indexOf(digits[i]);
|
|
62
|
+
inverted += BASE_62[61 - idx];
|
|
63
|
+
}
|
|
64
|
+
const prefixCode = 90 - (digits.length - 1); // 90 is 'Z'
|
|
65
|
+
const prefix = String.fromCharCode(Math.max(prefixCode, 65)); // clamp to 'A'
|
|
66
|
+
return prefix + inverted;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Decodes a prefix-encoded integer string back into a number.
|
|
72
|
+
*/
|
|
73
|
+
export function decodeInteger(s: string): number {
|
|
74
|
+
if (!s || s.length < 2) return 0;
|
|
75
|
+
const prefix = s[0];
|
|
76
|
+
if (prefix >= 'a' && prefix <= 'z') {
|
|
77
|
+
const len = prefix.charCodeAt(0) - 97 + 1;
|
|
78
|
+
const digits = s.slice(1, 1 + len);
|
|
79
|
+
return fromBase62(digits);
|
|
80
|
+
} else if (prefix >= 'A' && prefix <= 'Z') {
|
|
81
|
+
const len = 90 - prefix.charCodeAt(0) + 1;
|
|
82
|
+
const inverted = s.slice(1, 1 + len);
|
|
83
|
+
let digits = '';
|
|
84
|
+
for (let i = 0; i < inverted.length; i++) {
|
|
85
|
+
const idx = BASE_62.indexOf(inverted[i]);
|
|
86
|
+
digits += idx !== -1 ? BASE_62[61 - idx] : '0';
|
|
87
|
+
}
|
|
88
|
+
const pos = fromBase62(digits);
|
|
89
|
+
return -pos - 1;
|
|
90
|
+
}
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Extracts the integer component from an order key.
|
|
96
|
+
*/
|
|
97
|
+
export function getIntegerPart(key: string): string {
|
|
98
|
+
if (!key || key.length < 2) return 'a0';
|
|
99
|
+
const prefix = key[0];
|
|
100
|
+
if (prefix >= 'a' && prefix <= 'z') {
|
|
101
|
+
const len = prefix.charCodeAt(0) - 97 + 1;
|
|
102
|
+
return key.slice(0, Math.min(key.length, 1 + len));
|
|
103
|
+
} else if (prefix >= 'A' && prefix <= 'Z') {
|
|
104
|
+
const len = 90 - prefix.charCodeAt(0) + 1;
|
|
105
|
+
return key.slice(0, Math.min(key.length, 1 + len));
|
|
106
|
+
}
|
|
107
|
+
return 'a0';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Computes a fractional midpoint suffix between suffixA and suffixB.
|
|
112
|
+
*/
|
|
113
|
+
function midpointSuffix(a: string, b: string | null): string {
|
|
114
|
+
let i = 0;
|
|
115
|
+
while (i < a.length && b !== null && i < b.length && a[i] === b[i]) {
|
|
116
|
+
i++;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const common = a.slice(0, i);
|
|
120
|
+
const charA = i < a.length ? a[i] : null;
|
|
121
|
+
const charB = b !== null && i < b.length ? b[i] : null;
|
|
122
|
+
|
|
123
|
+
const digitA = charA !== null ? BASE_62.indexOf(charA) : 0;
|
|
124
|
+
const digitB = charB !== null ? BASE_62.indexOf(charB) : BASE_62.length;
|
|
125
|
+
|
|
126
|
+
if (digitB - digitA > 1) {
|
|
127
|
+
const mid = Math.floor((digitA + digitB) / 2);
|
|
128
|
+
return common + BASE_62[mid];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (charA !== null) {
|
|
132
|
+
const restA = a.slice(i + 1);
|
|
133
|
+
const restB = b !== null && i < b.length && charA === charB ? b.slice(i + 1) : null;
|
|
134
|
+
return common + charA + midpointSuffix(restA, restB);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (b !== null && i < b.length) {
|
|
138
|
+
if (digitB === 0) {
|
|
139
|
+
const restB = b.slice(i + 1);
|
|
140
|
+
return common + '0' + midpointSuffix('', restB);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return common + 'V';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Generates a default initial order key for an index with optional spacing.
|
|
149
|
+
* E.g. index 0 -> "a0", index 1 (spacing 2) -> "a2", index 2 -> "a4"
|
|
150
|
+
*/
|
|
151
|
+
export function getInitialKey(index: number, spacing: number = 2): string {
|
|
152
|
+
return encodeInteger(Math.max(0, index) * Math.max(1, spacing));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Generates a single key strictly between keys `a` and `b` (a < key < b).
|
|
157
|
+
* Handles null/undefined for lower and upper bounds.
|
|
158
|
+
*/
|
|
159
|
+
export function generateKeyBetween(
|
|
160
|
+
a: string | null | undefined,
|
|
161
|
+
b: string | null | undefined
|
|
162
|
+
): string {
|
|
163
|
+
const hasA = a !== null && a !== undefined && a !== '';
|
|
164
|
+
const hasB = b !== null && b !== undefined && b !== '';
|
|
165
|
+
|
|
166
|
+
if (!hasA && !hasB) {
|
|
167
|
+
return 'a0';
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!hasA && hasB) {
|
|
171
|
+
const intB = getIntegerPart(b!);
|
|
172
|
+
if (intB === b) {
|
|
173
|
+
const valB = decodeInteger(intB);
|
|
174
|
+
return encodeInteger(valB - 1);
|
|
175
|
+
}
|
|
176
|
+
// If b has fractional suffix (e.g. "a0V"), integer part is already strictly smaller than b
|
|
177
|
+
return intB;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (hasA && !hasB) {
|
|
181
|
+
const intA = getIntegerPart(a!);
|
|
182
|
+
const valA = decodeInteger(intA);
|
|
183
|
+
return encodeInteger(valA + 1);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Both a and b are present
|
|
187
|
+
if (a! >= b!) {
|
|
188
|
+
// Graceful fallback for inverted or equal keys
|
|
189
|
+
return a! + 'V';
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const intA = getIntegerPart(a!);
|
|
193
|
+
const intB = getIntegerPart(b!);
|
|
194
|
+
|
|
195
|
+
if (intA !== intB) {
|
|
196
|
+
const valA = decodeInteger(intA);
|
|
197
|
+
const valB = decodeInteger(intB);
|
|
198
|
+
|
|
199
|
+
if (valB - valA > 1) {
|
|
200
|
+
const midVal = Math.floor((valA + valB) / 2);
|
|
201
|
+
return encodeInteger(midVal);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (valB - valA === 1) {
|
|
205
|
+
const suffixA = a!.slice(intA.length);
|
|
206
|
+
return intA + midpointSuffix(suffixA, null);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Same integer part or fractional subdivision
|
|
211
|
+
const suffixA = a!.slice(intA.length);
|
|
212
|
+
const suffixB = b!.slice(intB.length);
|
|
213
|
+
return intA + midpointSuffix(suffixA, suffixB);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Generates `count` keys in strictly ascending order between keys `a` and `b`.
|
|
218
|
+
* Uses a balanced divide-and-conquer partition to guarantee even spacing.
|
|
219
|
+
*/
|
|
220
|
+
export function generateNKeysBetween(
|
|
221
|
+
a: string | null | undefined,
|
|
222
|
+
b: string | null | undefined,
|
|
223
|
+
count: number
|
|
224
|
+
): string[] {
|
|
225
|
+
if (count <= 0) return [];
|
|
226
|
+
if (count === 1) return [generateKeyBetween(a, b)];
|
|
227
|
+
|
|
228
|
+
const result: string[] = new Array(count);
|
|
229
|
+
|
|
230
|
+
function fill(
|
|
231
|
+
leftKey: string | null | undefined,
|
|
232
|
+
rightKey: string | null | undefined,
|
|
233
|
+
startIdx: number,
|
|
234
|
+
endIdx: number
|
|
235
|
+
) {
|
|
236
|
+
if (startIdx > endIdx) return;
|
|
237
|
+
const midIdx = Math.floor((startIdx + endIdx) / 2);
|
|
238
|
+
const midKey = generateKeyBetween(leftKey, rightKey);
|
|
239
|
+
result[midIdx] = midKey;
|
|
240
|
+
fill(leftKey, midKey, startIdx, midIdx - 1);
|
|
241
|
+
fill(midKey, rightKey, midIdx + 1, endIdx);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
fill(a, b, 0, count - 1);
|
|
245
|
+
return result;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Standard comparator for fractional order indices.
|
|
250
|
+
* Uses strict code-point comparison matching Firestore, SQLite, and JavaScript array.sort().
|
|
251
|
+
*/
|
|
252
|
+
export function compareOrderIndices(
|
|
253
|
+
a: string | null | undefined,
|
|
254
|
+
b: string | null | undefined
|
|
255
|
+
): number {
|
|
256
|
+
if (!a && !b) return 0;
|
|
257
|
+
if (!a) return 1;
|
|
258
|
+
if (!b) return -1;
|
|
259
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
260
|
+
}
|
package/src/utils/index.ts
CHANGED
package/src/utils/key.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { generateProjectKey, validateProjectKey, formatTaskKey } from './key.js';
|
|
2
|
+
import { generateProjectKey, validateProjectKey, formatTaskKey, isTempTaskId } from './key.js';
|
|
3
3
|
|
|
4
4
|
describe('Project Key Utilities', () => {
|
|
5
5
|
describe('generateProjectKey', () => {
|
|
@@ -52,4 +52,25 @@ describe('Project Key Utilities', () => {
|
|
|
52
52
|
expect(formatTaskKey('', 10)).toBe('TASK-10');
|
|
53
53
|
});
|
|
54
54
|
});
|
|
55
|
+
|
|
56
|
+
describe('isTempTaskId', () => {
|
|
57
|
+
it('detects temp_ prefixed IDs', () => {
|
|
58
|
+
expect(isTempTaskId('temp_123456_abc')).toBe(true);
|
|
59
|
+
expect(isTempTaskId('#temp_123456_abc')).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('detects temp- prefixed IDs', () => {
|
|
63
|
+
expect(isTempTaskId('temp-task-1')).toBe(true);
|
|
64
|
+
expect(isTempTaskId('#temp-task-1')).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('returns false for permanent or invalid IDs', () => {
|
|
68
|
+
expect(isTempTaskId('task_123')).toBe(false);
|
|
69
|
+
expect(isTempTaskId('PROJ-1')).toBe(false);
|
|
70
|
+
expect(isTempTaskId(null)).toBe(false);
|
|
71
|
+
expect(isTempTaskId(undefined)).toBe(false);
|
|
72
|
+
expect(isTempTaskId('')).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
55
75
|
});
|
|
76
|
+
|
package/src/utils/key.ts
CHANGED
|
@@ -49,3 +49,14 @@ export function formatTaskKey(projectKey: string, taskNumber: number): string {
|
|
|
49
49
|
const prefix = projectKey ? projectKey.toUpperCase() : 'TASK';
|
|
50
50
|
return `${prefix}-${taskNumber}`;
|
|
51
51
|
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Checks whether a given task ID represents a temporary/optimistic task
|
|
55
|
+
* (e.g. temp_1790... or temp-task-...) while it is actively being created.
|
|
56
|
+
*/
|
|
57
|
+
export function isTempTaskId(id?: string | null): boolean {
|
|
58
|
+
if (!id || typeof id !== 'string') return false;
|
|
59
|
+
const clean = id.trim().replace(/^#/, '');
|
|
60
|
+
return clean.startsWith('temp_') || clean.startsWith('temp-');
|
|
61
|
+
}
|
|
62
|
+
|