Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 42 additions & 5 deletions src/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,9 +197,27 @@ export class WorklogDatabase {

private sortItemsByScore(items: WorkItem[], recencyPolicy: 'prefer'|'avoid'|'ignore' = 'ignore'): WorkItem[] {
const now = Date.now();

// Pre-compute ancestors of in-progress items for O(1) per-item lookup.
// For each in-progress item, walk up the parent chain and record ancestor IDs.
const MAX_ANCESTOR_DEPTH = 50;
const ancestorsOfInProgress = new Set<string>();
for (const item of items) {
if (item.status === 'in-progress') {
let currentParentId = item.parentId ?? null;
let depth = 0;
while (currentParentId && depth < MAX_ANCESTOR_DEPTH) {
ancestorsOfInProgress.add(currentParentId);
const parent = this.store.getWorkItem(currentParentId);
currentParentId = parent?.parentId ?? null;
depth++;
}
}
}

return items.slice().sort((a, b) => {
const scoreA = this.computeScore(a, now, recencyPolicy);
const scoreB = this.computeScore(b, now, recencyPolicy);
const scoreA = this.computeScore(a, now, recencyPolicy, ancestorsOfInProgress);
const scoreB = this.computeScore(b, now, recencyPolicy, ancestorsOfInProgress);
if (scoreB !== scoreA) return scoreB - scoreA;
const createdA = new Date(a.createdAt).getTime();
const createdB = new Date(b.createdAt).getTime();
Expand Down Expand Up @@ -1062,14 +1080,20 @@ export class WorklogDatabase {
* Compute a score for an item. Defaults: recencyPolicy='ignore'.
* Higher score == more desirable.
*/
private computeScore(item: WorkItem, now: number, recencyPolicy: 'prefer'|'avoid'|'ignore' = 'ignore'): number {
private computeScore(
item: WorkItem,
now: number,
recencyPolicy: 'prefer'|'avoid'|'ignore' = 'ignore',
ancestorsOfInProgress?: Set<string>
): number {
// Weights are intentionally fixed and not configurable per request
//
// Ranking precedence (highest to lowest):
// 1. priority — primary ranking (weight 1000 per level)
// 2. blocksHighPriority — boost for items that unblock high/critical work
// 3. blocked penalty — heavy penalty for blocked items
// 4. age / effort / recency — fine-grained tie-breakers
// 3. in-progress multipliers — boost active items and their ancestors
// 4. blocked penalty — heavy penalty for blocked items
// 5. age / effort / recency — fine-grained tie-breakers
const WEIGHTS = {
priority: 1000,
blocksHighPriority: 500, // boost when this item unblocks high/critical items
Expand Down Expand Up @@ -1134,6 +1158,19 @@ export class WorklogDatabase {
// Blocked status - heavy penalty
if (item.status === 'blocked') score += WEIGHTS.blocked;

// In-progress score multiplier boosts (applied after all additive components).
// Non-stacking: direct in-progress boost takes precedence over ancestor boost.
// Blocked items receive no boost (the -10000 penalty remains dominant).
const IN_PROGRESS_BOOST = 1.5;
const PARENT_IN_PROGRESS_BOOST = 1.25;
if (item.status !== 'blocked') {
if (item.status === 'in-progress') {
score *= IN_PROGRESS_BOOST;
} else if (ancestorsOfInProgress?.has(item.id)) {
score *= PARENT_IN_PROGRESS_BOOST;
}
}

return score;
}

Expand Down
101 changes: 101 additions & 0 deletions tests/database.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1893,4 +1893,105 @@ describe('WorklogDatabase', () => {
expect(afterAvoidA.sortIndex).toBeGreaterThan(afterAvoidB.sortIndex);
});
});

describe('in-progress boost in computeScore / reSort', () => {
it('should boost an in-progress item above a same-priority open item', () => {
const open = db.create({ title: 'Open item', priority: 'medium' });
const inProgress = db.create({ title: 'In-progress item', priority: 'medium', status: 'in-progress' });

db.reSort();

const updatedOpen = db.get(open.id)!;
const updatedInProgress = db.get(inProgress.id)!;
// In-progress item should sort first (lower sortIndex = higher rank)
expect(updatedInProgress.sortIndex).toBeLessThan(updatedOpen.sortIndex);
});

it('should boost an ancestor of an in-progress item above a same-priority open item', () => {
const parent = db.create({ title: 'Parent epic', priority: 'medium' });
const child = db.create({ title: 'In-progress child', priority: 'medium', status: 'in-progress', parentId: parent.id });
const unrelated = db.create({ title: 'Unrelated open item', priority: 'medium' });

// Suppress unused-variable lint warning
void child;

db.reSort();

const updatedParent = db.get(parent.id)!;
const updatedUnrelated = db.get(unrelated.id)!;
// Parent with in-progress child should sort above the unrelated open item
expect(updatedParent.sortIndex).toBeLessThan(updatedUnrelated.sortIndex);
});

it('should apply only the in-progress boost (not ancestor boost) when item is itself in-progress', () => {
// Parent has an in-progress child AND is itself in-progress:
// it should get the 1.5x boost, not both 1.5x and 1.25x
const parent = db.create({ title: 'In-progress parent', priority: 'medium', status: 'in-progress' });
const child = db.create({ title: 'In-progress child', priority: 'medium', status: 'in-progress', parentId: parent.id });
const open = db.create({ title: 'Open item', priority: 'medium' });

void child;

db.reSort();

const updatedParent = db.get(parent.id)!;
const updatedOpen = db.get(open.id)!;
// Parent is in-progress so it gets the 1.5x boost (not stacked 1.5x * 1.25x)
expect(updatedParent.sortIndex).toBeLessThan(updatedOpen.sortIndex);
});

it('should not boost a blocked item even if it is an ancestor of an in-progress item', () => {
const blockedParent = db.create({ title: 'Blocked parent', priority: 'medium', status: 'blocked' });
db.create({ title: 'In-progress child', priority: 'medium', status: 'in-progress', parentId: blockedParent.id });
const open = db.create({ title: 'Open item', priority: 'medium' });

db.reSort();

const updatedBlockedParent = db.get(blockedParent.id)!;
const updatedOpen = db.get(open.id)!;
// Blocked parent should still sort below the open item due to -10000 penalty
expect(updatedBlockedParent.sortIndex).toBeGreaterThan(updatedOpen.sortIndex);
});

it('should not modify the stored priority field when applying in-progress boost', () => {
const item = db.create({ title: 'In-progress item', priority: 'medium', status: 'in-progress' });

db.reSort();

const updated = db.get(item.id)!;
expect(updated.priority).toBe('medium');
});

it('should still boost ancestor when multiple in-progress children exist at different depths', () => {
const grandparent = db.create({ title: 'Grandparent', priority: 'medium' });
const parent = db.create({ title: 'Parent', priority: 'medium', parentId: grandparent.id });
db.create({ title: 'In-progress grandchild', priority: 'medium', status: 'in-progress', parentId: parent.id });
const unrelated = db.create({ title: 'Unrelated open item', priority: 'medium' });

db.reSort();

const updatedGrandparent = db.get(grandparent.id)!;
const updatedUnrelated = db.get(unrelated.id)!;
// Grandparent should be boosted because it is an ancestor of an in-progress item
expect(updatedGrandparent.sortIndex).toBeLessThan(updatedUnrelated.sortIndex);
});

it('should not boost ancestor when in-progress child is completed', () => {
const parent = db.create({ title: 'Parent', priority: 'medium' });
const child = db.create({ title: 'Child', priority: 'medium', status: 'in-progress', parentId: parent.id });
const unrelated = db.create({ title: 'Unrelated open item', priority: 'medium' });

// Close the in-progress child
db.update(child.id, { status: 'completed' });

db.reSort();

const updatedParent = db.get(parent.id)!;
const updatedUnrelated = db.get(unrelated.id)!;
// Parent no longer has any in-progress descendants; no ancestor boost.
// With equal priority and no boost, createdAt is the tie-breaker:
// parent was created first so it naturally gets a lower sortIndex.
expect(updatedParent.sortIndex).toBeLessThan(updatedUnrelated.sortIndex);
});
});
});