From 9f582263b80ec8825ecf321a8eb09bf919917a89 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 21:02:16 +0100 Subject: [PATCH] feat(maestro): project list sort/group/filter helpers (#59) --- web/src/projectList.test.ts | 126 ++++++++++++++++++++++++++++++++++++ web/src/projectList.ts | 76 ++++++++++++++++++++++ 2 files changed, 202 insertions(+) create mode 100644 web/src/projectList.test.ts create mode 100644 web/src/projectList.ts diff --git a/web/src/projectList.test.ts b/web/src/projectList.test.ts new file mode 100644 index 0000000..cbf5ac9 --- /dev/null +++ b/web/src/projectList.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; + +import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js"; +import type { Project } from "./types.js"; + +const NOW = new Date("2026-08-23T12:00:00Z"); + +function project(overrides: Partial): Project { + return { + path: `/home/user/${overrides.name ?? "proj"}`, + name: "proj", + is_worktree: false, + last_activity: undefined, + last_command: undefined, + ...overrides, + } as Project; +} + +describe("isStale", () => { + it("marks projects with no known activity stale", () => { + expect(isStale(project({ name: "never" }), NOW)).toBe(true); + }); + + it("is fresh within 7 days and stale beyond", () => { + const fresh = project({ name: "fresh", last_activity: "2026-08-17T00:00:00Z" }); + const stale = project({ name: "stale", last_activity: "2026-08-16T11:59:00Z" }); + expect(isStale(fresh, NOW)).toBe(false); + expect(isStale(stale, NOW)).toBe(true); + }); +}); + +describe("sortProjects", () => { + it("sorts newest-activity first", () => { + const a = project({ name: "a", last_activity: "2026-08-20T00:00:00Z" }); + const b = project({ name: "b", last_activity: "2026-08-22T00:00:00Z" }); + expect(sortProjects([a, b], NOW).map((p) => p.name)).toEqual(["b", "a"]); + }); + + it("sorts stale rows after fresh ones, each block newest first", () => { + const freshOld = project({ name: "fresh-old", last_activity: "2026-08-18T00:00:00Z" }); + const freshNew = project({ name: "fresh-new", last_activity: "2026-08-23T00:00:00Z" }); + const staleNew = project({ name: "stale-new", last_activity: "2026-08-10T00:00:00Z" }); + const staleNone = project({ name: "stale-none" }); + const sorted = sortProjects([staleNew, freshOld, staleNone, freshNew], NOW); + expect(sorted.map((p) => p.name)).toEqual(["fresh-new", "fresh-old", "stale-new", "stale-none"]); + }); + + it("does not mutate the input", () => { + const a = project({ name: "a", last_activity: "2026-08-01T00:00:00Z" }); + const b = project({ name: "b", last_activity: "2026-08-02T00:00:00Z" }); + sortProjects([a, b], NOW); + expect(a.name).toBe("a"); + }); +}); + +describe("groupProjects", () => { + it("keeps plain repositories as their own single-entry group", () => { + const solo = project({ name: "solo" }); + expect(groupProjects(sortProjects([solo], NOW))).toEqual([{ main: solo, worktrees: [] }]); + }); + + it("nests worktrees under their base repository even when fresher than it", () => { + const main = project({ name: "maestro" }); + const wt = project({ + name: "wt", + path: "/elsewhere/wt", + is_worktree: true, + base_repo: main.path, + last_activity: "2026-08-23T00:00:00Z", + }); + const groups = groupProjects(sortProjects([wt, main], NOW)); + expect(groups).toHaveLength(1); + expect(groups[0].main).toBe(main); + expect(groups[0].worktrees).toEqual([wt]); + }); + + it("orders groups by the freshest member and worktrees by activity", () => { + const mainA = project({ name: "a", path: "/w/a", last_activity: "2026-08-01T00:00:00Z" }); + const wtA = project({ + name: "a-wt", + path: "/w/a-wt", + is_worktree: true, + base_repo: "/w/a", + last_activity: "2026-08-20T00:00:00Z", + }); + const mainB = project({ name: "b", path: "/w/b", last_activity: "2026-08-15T00:00:00Z" }); + const wtB1 = project({ + name: "b-wt1", + path: "/w/b-wt1", + is_worktree: true, + base_repo: "/w/b", + last_activity: "2026-08-19T00:00:00Z", + }); + const wtB2 = project({ + name: "b-wt2", + path: "/w/b-wt2", + is_worktree: true, + base_repo: "/w/b", + last_activity: "2026-08-21T00:00:00Z", + }); + + const groups = groupProjects(sortProjects([mainA, mainB, wtB1, wtA, wtB2], NOW)); + expect(groups.map((g) => g.main.name)).toEqual(["b", "a"]); + const gA = groups.find((g) => g.main.name === "a")!; + const gB = groups.find((g) => g.main.name === "b")!; + expect(gA.worktrees.map((p) => p.name)).toEqual(["a-wt"]); + expect(gB.worktrees.map((p) => p.name)).toEqual(["b-wt2", "b-wt1"]); + }); +}); + +describe("filterProjects", () => { + const maestro = project({ name: "maestro", path: "/home/u/git/maestro" }); + const tea = project({ name: "tea", path: "/home/u/work/tea" }); + + it("matches on name case-insensitively", () => { + expect(filterProjects([maestro, tea], "MAES")).toEqual([maestro]); + }); + + it("matches on the full path", () => { + expect(filterProjects([maestro, tea], "work")).toEqual([tea]); + }); + + it("returns everything unchanged for an empty query", () => { + expect(filterProjects([maestro, tea], " ")).toEqual([maestro, tea]); + }); +}); diff --git a/web/src/projectList.ts b/web/src/projectList.ts new file mode 100644 index 0000000..4a44ca6 --- /dev/null +++ b/web/src/projectList.ts @@ -0,0 +1,76 @@ +// Pure helpers behind the spawn form's project picker (#59): staleness, +// recency ordering, worktree grouping, and free-text filtering. The daemon +// reports the timestamps; what "stale" means is a client decision (#54). + +import type { Project } from "./types.js"; + +/** Projects whose last activity is older than this are dimmed and sort after + * fresh ones — still reachable, visibly stale (#59). */ +export const STALE_AFTER_DAYS = 7; + +export function isStale(project: Project, now: Date): boolean { + if (!project.last_activity) return true; + const age = now.getTime() - Date.parse(project.last_activity); + return !(age >= 0) || age > STALE_AFTER_DAYS * 24 * 60 * 60 * 1000; +} + +/** Fresh rows first, each block newest-activity-first; unknown or future + * timestamps sort as stalest. Ties fall back to name so the order is stable. */ +export function sortProjects(projects: Project[], now: Date): Project[] { + return [...projects].sort((a, b) => { + const staleDiff = (isStale(a, now) ? 1 : 0) - (isStale(b, now) ? 1 : 0); + if (staleDiff !== 0) return staleDiff; + const ta = timeOf(a); + const tb = timeOf(b); + if (ta !== tb) return tb - ta; + return a.name.localeCompare(b.name) || a.path.localeCompare(b.path); + }); +} + +function timeOf(project: Project): number { + return project.last_activity ? Date.parse(project.last_activity) : Number.NEGATIVE_INFINITY; +} + +/** One repository group: the main checkout followed by its linked worktrees. + * A group's position follows its freshest member, since a worktree worked on + * today should pull its (dormant) base repo up with it. */ +export interface ProjectGroup { + main: Project; + worktrees: Project[]; +} + +/** Group `sorted` projects by their base repository. A worktree nests under + * its main checkout even when the main sorts later; a main checkout missing + * from the list doesn't drop its worktrees. */ +export function groupProjects(sorted: Project[]): ProjectGroup[] { + const members = new Map(); + const keys: string[] = []; + for (const p of sorted) { + const key = p.base_repo ?? p.path; + let list = members.get(key); + if (!list) { + list = []; + members.set(key, list); + keys.push(key); + } + list.push(p); + } + return keys.map((key) => { + const list = members.get(key)!; + const main = list.find((p) => p.path === key && !p.is_worktree) ?? list[0]; + // Keep the daemon's newest-first order within the group. + const worktrees = list + .filter((p) => p !== main) + .sort((a, b) => timeOf(b) - timeOf(a)); + return { main, worktrees }; + }); +} + +/** Substring filter over project name and full path, case-insensitive. */ +export function filterProjects(projects: Project[], query: string): Project[] { + const q = query.trim().toLowerCase(); + if (!q) return [...projects]; + return projects.filter( + (p) => p.name.toLowerCase().includes(q) || p.path.toLowerCase().includes(q), + ); +}