fix(KB-186): add ID-based tiebreaker to sort comparators for stable ordering

- Add numeric ID tiebreaker to TaskStore.listTasks() when createdAt timestamps match
- Add same tiebreaker to InlineCreateCard and TaskDetailModal dependency dropdown sorts
- Add store-sort integration test verifying ascending ID order with identical timestamps
- Add InlineCreateCard tests for identical-timestamp dropdown ordering and search filtering
- Add TaskDetailModal test for identical-timestamp dependency dropdown ordering
This commit is contained in:
Dustin Byrne
2026-03-28 19:40:17 -04:00
parent b5717562f8
commit 478ba72044
6 changed files with 132 additions and 3 deletions

View File

@@ -0,0 +1,55 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { TaskStore } from "../store.js";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-store-sort-test-"));
}
describe("TaskStore.listTasks() sort order", () => {
let rootDir: string;
let store: TaskStore;
beforeEach(async () => {
rootDir = makeTmpDir();
store = new TaskStore(rootDir);
await store.init();
});
afterEach(async () => {
store.stopWatching();
await rm(rootDir, { recursive: true, force: true });
});
it("returns tasks with identical createdAt in ascending ID order", async () => {
// Create three tasks — they may get the same createdAt if created fast enough
const t1 = await store.createTask({ description: "Task one" });
const t2 = await store.createTask({ description: "Task two" });
const t3 = await store.createTask({ description: "Task three" });
// Force identical createdAt by rewriting the task.json files
const { readFile, writeFile } = await import("node:fs/promises");
const tasksDir = join(rootDir, ".kb", "tasks");
const sameTimestamp = "2026-06-01T00:00:00Z";
for (const t of [t1, t2, t3]) {
const jsonPath = join(tasksDir, t.id, "task.json");
const data = JSON.parse(await readFile(jsonPath, "utf-8"));
data.createdAt = sameTimestamp;
data.updatedAt = sameTimestamp;
await writeFile(jsonPath, JSON.stringify(data, null, 2));
}
const tasks = await store.listTasks();
const ids = tasks.map((t) => t.id);
// Should be ascending by numeric ID portion
const nums = ids.map((id) => parseInt(id.slice(id.lastIndexOf("-") + 1), 10));
for (let i = 1; i < nums.length; i++) {
expect(nums[i]).toBeGreaterThan(nums[i - 1]);
}
});
});

View File

@@ -249,7 +249,13 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
}
return tasks.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
return tasks.sort((a, b) => {
const cmp = a.createdAt.localeCompare(b.createdAt);
if (cmp !== 0) return cmp;
const aNum = parseInt(a.id.slice(a.id.lastIndexOf("-") + 1), 10) || 0;
const bNum = parseInt(b.id.slice(b.id.lastIndexOf("-") + 1), 10) || 0;
return aNum - bNum;
});
}
async moveTask(id: string, toColumn: Column): Promise<Task> {

View File

@@ -204,7 +204,13 @@ export function InlineCreateCard({ tasks, onSubmit, onCancel, addToast }: Inline
(t.description && t.description.toLowerCase().includes(term))
)
: [...tasks]
).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
).sort((a, b) => {
const cmp = b.createdAt.localeCompare(a.createdAt);
if (cmp !== 0) return cmp;
const aNum = parseInt(a.id.slice(a.id.lastIndexOf("-") + 1), 10) || 0;
const bNum = parseInt(b.id.slice(b.id.lastIndexOf("-") + 1), 10) || 0;
return bNum - aNum;
});
return (
<div className="dep-dropdown" onMouseDown={(e) => e.preventDefault()}>
<input

View File

@@ -239,7 +239,13 @@ export function TaskDetailModal({
const availableTasks = tasks
.filter((t) => t.id !== task.id && !dependencies.includes(t.id))
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
.sort((a, b) => {
const cmp = b.createdAt.localeCompare(a.createdAt);
if (cmp !== 0) return cmp;
const aNum = parseInt(a.id.slice(a.id.lastIndexOf("-") + 1), 10) || 0;
const bNum = parseInt(b.id.slice(b.id.lastIndexOf("-") + 1), 10) || 0;
return bNum - aNum;
});
const transitions = VALID_TRANSITIONS[task.column] || [];

View File

@@ -133,6 +133,34 @@ describe("InlineCreateCard dependency dropdown sort order", () => {
});
});
describe("InlineCreateCard dependency dropdown sort with identical timestamps", () => {
const sameTimeTasks: Task[] = [
{ id: "KB-001", title: "First", description: "First task", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
{ id: "KB-002", title: "Second", description: "Second task", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
{ id: "KB-003", title: "Third", description: "Third task", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
];
it("renders tasks with identical createdAt sorted newest-ID-first (descending numeric ID)", () => {
renderCard(sameTimeTasks);
fireEvent.click(screen.getByText(/Deps/));
const items = document.querySelectorAll(".dep-dropdown-item");
expect(items).toHaveLength(3);
const ids = Array.from(items).map((el) => el.querySelector(".dep-dropdown-id")?.textContent);
expect(ids).toEqual(["KB-003", "KB-002", "KB-001"]);
});
it("preserves newest-ID-first order when search filter is applied with identical timestamps", () => {
renderCard(sameTimeTasks);
fireEvent.click(screen.getByText(/Deps/));
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
fireEvent.change(input, { target: { value: "KB-00" } });
const items = document.querySelectorAll(".dep-dropdown-item");
expect(items).toHaveLength(3);
const ids = Array.from(items).map((el) => el.querySelector(".dep-dropdown-id")?.textContent);
expect(ids).toEqual(["KB-003", "KB-002", "KB-001"]);
});
});
describe("InlineCreateCard dependency dropdown search", () => {
const testTasks: Task[] = [
{ id: "KB-001", title: "Fix login", description: "Login page broken", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },

View File

@@ -540,6 +540,34 @@ describe("TaskDetailModal", () => {
expect(ids).toEqual(["KB-003", "KB-002", "KB-001"]);
});
it("renders tasks with identical createdAt sorted newest-ID-first in dependency dropdown", () => {
const allTasks: Task[] = [
{ id: "KB-001", description: "First", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
{ id: "KB-002", description: "Second", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
{ id: "KB-003", description: "Third", column: "todo" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
{ id: "KB-099", description: "Self", column: "in-progress" as Column, dependencies: [], steps: [], currentStep: 0, log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z" },
];
render(
<TaskDetailModal
task={makeTask({ dependencies: [] })}
tasks={allTasks}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
addToast={noop}
/>,
);
fireEvent.click(screen.getByText("Add Dependency"));
const items = document.querySelectorAll(".dep-dropdown-item");
expect(items).toHaveLength(3);
const ids = Array.from(items).map((el) => el.querySelector(".dep-dropdown-id")?.textContent);
expect(ids).toEqual(["KB-003", "KB-002", "KB-001"]);
});
describe("tab toggle", () => {
it("defaults to the Definition tab", () => {
const { container } = render(