Skip to content

Commit 116039e

Browse files
feat: boost in-progress items and ancestors in computeScore sorting
Co-authored-by: rgardler-msft <108765066+rgardler-msft@users.noreply.github.com>
1 parent a2d8b6b commit 116039e

2 files changed

Lines changed: 143 additions & 5 deletions

File tree

src/database.ts

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -197,9 +197,27 @@ export class WorklogDatabase {
197197

198198
private sortItemsByScore(items: WorkItem[], recencyPolicy: 'prefer'|'avoid'|'ignore' = 'ignore'): WorkItem[] {
199199
const now = Date.now();
200+
201+
// Pre-compute ancestors of in-progress items for O(1) per-item lookup.
202+
// For each in-progress item, walk up the parent chain and record ancestor IDs.
203+
const MAX_ANCESTOR_DEPTH = 50;
204+
const ancestorsOfInProgress = new Set<string>();
205+
for (const item of items) {
206+
if (item.status === 'in-progress') {
207+
let currentParentId = item.parentId ?? null;
208+
let depth = 0;
209+
while (currentParentId && depth < MAX_ANCESTOR_DEPTH) {
210+
ancestorsOfInProgress.add(currentParentId);
211+
const parent = this.store.getWorkItem(currentParentId);
212+
currentParentId = parent?.parentId ?? null;
213+
depth++;
214+
}
215+
}
216+
}
217+
200218
return items.slice().sort((a, b) => {
201-
const scoreA = this.computeScore(a, now, recencyPolicy);
202-
const scoreB = this.computeScore(b, now, recencyPolicy);
219+
const scoreA = this.computeScore(a, now, recencyPolicy, ancestorsOfInProgress);
220+
const scoreB = this.computeScore(b, now, recencyPolicy, ancestorsOfInProgress);
203221
if (scoreB !== scoreA) return scoreB - scoreA;
204222
const createdA = new Date(a.createdAt).getTime();
205223
const createdB = new Date(b.createdAt).getTime();
@@ -1062,14 +1080,20 @@ export class WorklogDatabase {
10621080
* Compute a score for an item. Defaults: recencyPolicy='ignore'.
10631081
* Higher score == more desirable.
10641082
*/
1065-
private computeScore(item: WorkItem, now: number, recencyPolicy: 'prefer'|'avoid'|'ignore' = 'ignore'): number {
1083+
private computeScore(
1084+
item: WorkItem,
1085+
now: number,
1086+
recencyPolicy: 'prefer'|'avoid'|'ignore' = 'ignore',
1087+
ancestorsOfInProgress?: Set<string>
1088+
): number {
10661089
// Weights are intentionally fixed and not configurable per request
10671090
//
10681091
// Ranking precedence (highest to lowest):
10691092
// 1. priority — primary ranking (weight 1000 per level)
10701093
// 2. blocksHighPriority — boost for items that unblock high/critical work
1071-
// 3. blocked penalty — heavy penalty for blocked items
1072-
// 4. age / effort / recency — fine-grained tie-breakers
1094+
// 3. in-progress multipliers — boost active items and their ancestors
1095+
// 4. blocked penalty — heavy penalty for blocked items
1096+
// 5. age / effort / recency — fine-grained tie-breakers
10731097
const WEIGHTS = {
10741098
priority: 1000,
10751099
blocksHighPriority: 500, // boost when this item unblocks high/critical items
@@ -1134,6 +1158,19 @@ export class WorklogDatabase {
11341158
// Blocked status - heavy penalty
11351159
if (item.status === 'blocked') score += WEIGHTS.blocked;
11361160

1161+
// In-progress score multiplier boosts (applied after all additive components).
1162+
// Non-stacking: direct in-progress boost takes precedence over ancestor boost.
1163+
// Blocked items receive no boost (the -10000 penalty remains dominant).
1164+
const IN_PROGRESS_BOOST = 1.5;
1165+
const PARENT_IN_PROGRESS_BOOST = 1.25;
1166+
if (item.status !== 'blocked') {
1167+
if (item.status === 'in-progress') {
1168+
score *= IN_PROGRESS_BOOST;
1169+
} else if (ancestorsOfInProgress?.has(item.id)) {
1170+
score *= PARENT_IN_PROGRESS_BOOST;
1171+
}
1172+
}
1173+
11371174
return score;
11381175
}
11391176

tests/database.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1893,4 +1893,105 @@ describe('WorklogDatabase', () => {
18931893
expect(afterAvoidA.sortIndex).toBeGreaterThan(afterAvoidB.sortIndex);
18941894
});
18951895
});
1896+
1897+
describe('in-progress boost in computeScore / reSort', () => {
1898+
it('should boost an in-progress item above a same-priority open item', () => {
1899+
const open = db.create({ title: 'Open item', priority: 'medium' });
1900+
const inProgress = db.create({ title: 'In-progress item', priority: 'medium', status: 'in-progress' });
1901+
1902+
db.reSort();
1903+
1904+
const updatedOpen = db.get(open.id)!;
1905+
const updatedInProgress = db.get(inProgress.id)!;
1906+
// In-progress item should sort first (lower sortIndex = higher rank)
1907+
expect(updatedInProgress.sortIndex).toBeLessThan(updatedOpen.sortIndex);
1908+
});
1909+
1910+
it('should boost an ancestor of an in-progress item above a same-priority open item', () => {
1911+
const parent = db.create({ title: 'Parent epic', priority: 'medium' });
1912+
const child = db.create({ title: 'In-progress child', priority: 'medium', status: 'in-progress', parentId: parent.id });
1913+
const unrelated = db.create({ title: 'Unrelated open item', priority: 'medium' });
1914+
1915+
// Suppress unused-variable lint warning
1916+
void child;
1917+
1918+
db.reSort();
1919+
1920+
const updatedParent = db.get(parent.id)!;
1921+
const updatedUnrelated = db.get(unrelated.id)!;
1922+
// Parent with in-progress child should sort above the unrelated open item
1923+
expect(updatedParent.sortIndex).toBeLessThan(updatedUnrelated.sortIndex);
1924+
});
1925+
1926+
it('should apply only the in-progress boost (not ancestor boost) when item is itself in-progress', () => {
1927+
// Parent has an in-progress child AND is itself in-progress:
1928+
// it should get the 1.5x boost, not both 1.5x and 1.25x
1929+
const parent = db.create({ title: 'In-progress parent', priority: 'medium', status: 'in-progress' });
1930+
const child = db.create({ title: 'In-progress child', priority: 'medium', status: 'in-progress', parentId: parent.id });
1931+
const open = db.create({ title: 'Open item', priority: 'medium' });
1932+
1933+
void child;
1934+
1935+
db.reSort();
1936+
1937+
const updatedParent = db.get(parent.id)!;
1938+
const updatedOpen = db.get(open.id)!;
1939+
// Parent is in-progress so it gets the 1.5x boost (not stacked 1.5x * 1.25x)
1940+
expect(updatedParent.sortIndex).toBeLessThan(updatedOpen.sortIndex);
1941+
});
1942+
1943+
it('should not boost a blocked item even if it is an ancestor of an in-progress item', () => {
1944+
const blockedParent = db.create({ title: 'Blocked parent', priority: 'medium', status: 'blocked' });
1945+
db.create({ title: 'In-progress child', priority: 'medium', status: 'in-progress', parentId: blockedParent.id });
1946+
const open = db.create({ title: 'Open item', priority: 'medium' });
1947+
1948+
db.reSort();
1949+
1950+
const updatedBlockedParent = db.get(blockedParent.id)!;
1951+
const updatedOpen = db.get(open.id)!;
1952+
// Blocked parent should still sort below the open item due to -10000 penalty
1953+
expect(updatedBlockedParent.sortIndex).toBeGreaterThan(updatedOpen.sortIndex);
1954+
});
1955+
1956+
it('should not modify the stored priority field when applying in-progress boost', () => {
1957+
const item = db.create({ title: 'In-progress item', priority: 'medium', status: 'in-progress' });
1958+
1959+
db.reSort();
1960+
1961+
const updated = db.get(item.id)!;
1962+
expect(updated.priority).toBe('medium');
1963+
});
1964+
1965+
it('should still boost ancestor when multiple in-progress children exist at different depths', () => {
1966+
const grandparent = db.create({ title: 'Grandparent', priority: 'medium' });
1967+
const parent = db.create({ title: 'Parent', priority: 'medium', parentId: grandparent.id });
1968+
db.create({ title: 'In-progress grandchild', priority: 'medium', status: 'in-progress', parentId: parent.id });
1969+
const unrelated = db.create({ title: 'Unrelated open item', priority: 'medium' });
1970+
1971+
db.reSort();
1972+
1973+
const updatedGrandparent = db.get(grandparent.id)!;
1974+
const updatedUnrelated = db.get(unrelated.id)!;
1975+
// Grandparent should be boosted because it is an ancestor of an in-progress item
1976+
expect(updatedGrandparent.sortIndex).toBeLessThan(updatedUnrelated.sortIndex);
1977+
});
1978+
1979+
it('should not boost ancestor when in-progress child is completed', () => {
1980+
const parent = db.create({ title: 'Parent', priority: 'medium' });
1981+
const child = db.create({ title: 'Child', priority: 'medium', status: 'in-progress', parentId: parent.id });
1982+
const unrelated = db.create({ title: 'Unrelated open item', priority: 'medium' });
1983+
1984+
// Close the in-progress child
1985+
db.update(child.id, { status: 'completed' });
1986+
1987+
db.reSort();
1988+
1989+
const updatedParent = db.get(parent.id)!;
1990+
const updatedUnrelated = db.get(unrelated.id)!;
1991+
// Parent no longer has any in-progress descendants; no ancestor boost.
1992+
// With equal priority and no boost, createdAt is the tie-breaker:
1993+
// parent was created first so it naturally gets a lower sortIndex.
1994+
expect(updatedParent.sortIndex).toBeLessThan(updatedUnrelated.sortIndex);
1995+
});
1996+
});
18961997
});

0 commit comments

Comments
 (0)