@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.
Files changed (66) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +41 -0
  3. package/dist/domain/entities.d.ts +4 -0
  4. package/dist/domain/entities.d.ts.map +1 -1
  5. package/dist/domain/entities.js +56 -0
  6. package/dist/domain/entities.js.map +1 -1
  7. package/dist/domain/metrics.d.ts.map +1 -1
  8. package/dist/domain/metrics.js +2 -1
  9. package/dist/domain/metrics.js.map +1 -1
  10. package/dist/domain/task-duration.test.js +161 -0
  11. package/dist/domain/task-duration.test.js.map +1 -1
  12. package/dist/engine/index.d.ts.map +1 -1
  13. package/dist/engine/index.js +113 -21
  14. package/dist/engine/index.js.map +1 -1
  15. package/dist/status.test.js +97 -1
  16. package/dist/status.test.js.map +1 -1
  17. package/dist/store/sqlite.d.ts.map +1 -1
  18. package/dist/store/sqlite.js +22 -7
  19. package/dist/store/sqlite.js.map +1 -1
  20. package/dist/types/index.d.ts +7 -0
  21. package/dist/types/index.d.ts.map +1 -1
  22. package/dist/utils/fractional-index.d.ts +50 -0
  23. package/dist/utils/fractional-index.d.ts.map +1 -0
  24. package/dist/utils/fractional-index.js +230 -0
  25. package/dist/utils/fractional-index.js.map +1 -0
  26. package/dist/utils/fractional-index.test.d.ts +2 -0
  27. package/dist/utils/fractional-index.test.d.ts.map +1 -0
  28. package/dist/utils/fractional-index.test.js +151 -0
  29. package/dist/utils/fractional-index.test.js.map +1 -0
  30. package/dist/utils/index.d.ts +1 -0
  31. package/dist/utils/index.d.ts.map +1 -1
  32. package/dist/utils/index.js +1 -0
  33. package/dist/utils/index.js.map +1 -1
  34. package/dist/utils/key.d.ts +5 -0
  35. package/dist/utils/key.d.ts.map +1 -1
  36. package/dist/utils/key.js +10 -0
  37. package/dist/utils/key.js.map +1 -1
  38. package/dist/utils/key.test.js +18 -1
  39. package/dist/utils/key.test.js.map +1 -1
  40. package/dist/utils/mentions.d.ts +13 -5
  41. package/dist/utils/mentions.d.ts.map +1 -1
  42. package/dist/utils/mentions.js +52 -16
  43. package/dist/utils/mentions.js.map +1 -1
  44. package/dist/utils/mentions.test.js +32 -1
  45. package/dist/utils/mentions.test.js.map +1 -1
  46. package/dist/utils/status.d.ts +80 -1
  47. package/dist/utils/status.d.ts.map +1 -1
  48. package/dist/utils/status.js +194 -0
  49. package/dist/utils/status.js.map +1 -1
  50. package/package.json +1 -1
  51. package/src/domain/entities.ts +56 -0
  52. package/src/domain/metrics.ts +2 -1
  53. package/src/domain/task-duration.test.ts +184 -0
  54. package/src/engine/index.ts +121 -28
  55. package/src/status.test.ts +130 -2
  56. package/src/store/sqlite.ts +28 -7
  57. package/src/types/index.ts +7 -0
  58. package/src/utils/fractional-index.test.ts +191 -0
  59. package/src/utils/fractional-index.ts +260 -0
  60. package/src/utils/index.ts +2 -0
  61. package/src/utils/key.test.ts +22 -1
  62. package/src/utils/key.ts +11 -0
  63. package/src/utils/mentions.test.ts +37 -1
  64. package/src/utils/mentions.ts +58 -16
  65. package/src/utils/status.ts +247 -0
  66. package/tsconfig.tsbuildinfo +1 -1
@@ -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,
@@ -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
+ }
@@ -2,4 +2,6 @@ export * from './status.js';
2
2
  export * from './key.js';
3
3
  export * from './workflow.js';
4
4
  export * from './mentions.js';
5
+ export * from './fractional-index.js';
6
+
5
7
 
@@ -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
+