diff options
Diffstat (limited to 'src/features/workspaces/logic')
| -rw-r--r-- | src/features/workspaces/logic/view-model.test.ts | 113 | ||||
| -rw-r--r-- | src/features/workspaces/logic/view-model.ts | 30 |
2 files changed, 142 insertions, 1 deletions
diff --git a/src/features/workspaces/logic/view-model.test.ts b/src/features/workspaces/logic/view-model.test.ts index 86342e7..44d2f31 100644 --- a/src/features/workspaces/logic/view-model.test.ts +++ b/src/features/workspaces/logic/view-model.test.ts @@ -1,6 +1,6 @@ import type { WorkspaceEntry } from "@dispatch/wire"; import { describe, expect, it } from "vitest"; -import { pageTitle, relativeTime } from "./view-model"; +import { applyStarred, pageTitle, relativeTime, sortWorkspaces } from "./view-model"; describe("relativeTime", () => { const now = 1_000_000_000_000; // 2001-09-09 @@ -37,6 +37,7 @@ describe("pageTitle", () => { title, defaultCwd: null, defaultComputerId: null, + starred: false, createdAt: 0, lastActivityAt: 0, conversationCount: 0, @@ -66,3 +67,113 @@ describe("pageTitle", () => { expect(pageTitle({ kind: "workspace", id: "b" }, list)).toBe("Dispatch: shared-title"); }); }); + +describe("sortWorkspaces", () => { + const entry = (id: string, starred: boolean, lastActivityAt: number): WorkspaceEntry => ({ + id, + title: id, + defaultCwd: null, + defaultComputerId: null, + starred, + createdAt: 0, + lastActivityAt, + conversationCount: 0, + }); + + it("puts starred workspaces before unstarred", () => { + const list = [entry("plain", false, 9_000), entry("star", true, 1_000)]; + expect(sortWorkspaces(list).map((w) => w.id)).toEqual(["star", "plain"]); + }); + + it("within the starred group, sorts by lastActivityAt desc", () => { + const list = [ + entry("old-star", true, 1_000), + entry("new-star", true, 5_000), + entry("plain", false, 9_000), + ]; + expect(sortWorkspaces(list).map((w) => w.id)).toEqual(["new-star", "old-star", "plain"]); + }); + + it("within the unstarred group, sorts by lastActivityAt desc", () => { + const list = [ + entry("star", true, 1_000), + entry("old-plain", false, 1_000), + entry("new-plain", false, 5_000), + ]; + expect(sortWorkspaces(list).map((w) => w.id)).toEqual(["star", "new-plain", "old-plain"]); + }); + + it("returns a new array (does not mutate the input)", () => { + const list = [entry("plain", false, 9_000), entry("star", true, 1_000)]; + const sorted = sortWorkspaces(list); + expect(sorted).not.toBe(list); + // Input order is preserved (not mutated). + expect(list.map((w) => w.id)).toEqual(["plain", "star"]); + expect(sorted.map((w) => w.id)).toEqual(["star", "plain"]); + }); + + it("handles an empty list", () => { + expect(sortWorkspaces([])).toEqual([]); + }); + + it("is stable for equal lastActivityAt within a group", () => { + const list = [ + entry("first", false, 5_000), + entry("second", false, 5_000), + entry("third", false, 5_000), + ]; + expect(sortWorkspaces(list).map((w) => w.id)).toEqual(["first", "second", "third"]); + }); +}); + +describe("applyStarred", () => { + const entry = (id: string, starred: boolean): WorkspaceEntry => ({ + id, + title: id, + defaultCwd: null, + defaultComputerId: null, + starred, + createdAt: 0, + lastActivityAt: 0, + conversationCount: 0, + }); + + it("sets the named workspace's starred flag", () => { + const list = [entry("a", false), entry("b", false)]; + const next = applyStarred(list, "b", true); + expect(next.map((w) => [w.id, w.starred])).toEqual([ + ["a", false], + ["b", true], + ]); + }); + + it("returns a new array (does not mutate the input)", () => { + const list = [entry("a", false)]; + const next = applyStarred(list, "a", true); + expect(next).not.toBe(list); + expect(list[0]?.starred).toBe(false); + expect(next[0]?.starred).toBe(true); + }); + + it("leaves other entries referentially unchanged (only the target is replaced)", () => { + const a = entry("a", false); + const b = entry("b", false); + const next = applyStarred([a, b], "b", true); + expect(next[0]).toBe(a); + expect(next[1]).not.toBe(b); + }); + + it("leaves the list unchanged when the id is absent (not yet loaded)", () => { + const list = [entry("a", false)]; + const next = applyStarred(list, "missing", true); + expect(next.map((w) => [w.id, w.starred])).toEqual([["a", false]]); + }); + + it("can revert by re-applying the previous value", () => { + const list = [entry("a", false)]; + const optimistic = applyStarred(list, "a", true); + expect(optimistic[0]?.starred).toBe(true); + const reverted = applyStarred(optimistic, "a", false); + expect(reverted[0]?.starred).toBe(false); + }); +}); diff --git a/src/features/workspaces/logic/view-model.ts b/src/features/workspaces/logic/view-model.ts index 6dd64c6..b994b7c 100644 --- a/src/features/workspaces/logic/view-model.ts +++ b/src/features/workspaces/logic/view-model.ts @@ -20,6 +20,36 @@ export function pageTitle(route: Route, workspaces: readonly WorkspaceEntry[]): } /** + * Sort workspaces for display: starred first, then most-recently-active. Pure: + * the list in, a NEW sorted array out (the input is not mutated). Starred + * workspaces jump to the top (the FE-side echo of their concurrency-priority); + * within each group (starred / not) `lastActivityAt` desc breaks ties, matching + * the backend's list ordering. Stable for equal `lastActivityAt`. + */ +export function sortWorkspaces<T extends WorkspaceEntry>(workspaces: readonly T[]): T[] { + return [...workspaces].sort((a, b) => { + if (a.starred !== b.starred) return a.starred ? -1 : 1; + return b.lastActivityAt - a.lastActivityAt; + }); +} + +/** + * Return a NEW list with the one workspace's `starred` flag set (immutably — + * the entry is replaced, the rest keep their identity). Pure: the optimistic + * star/unstar transformation shared by the apply + the error revert. A missing + * `id` (not yet in the list — e.g. starring a workspace the home view hasn't + * loaded) leaves the list unchanged; the backend's create-on-miss still applies + * server-side and a subsequent refresh reconciles. + */ +export function applyStarred<T extends WorkspaceEntry>( + workspaces: readonly T[], + id: string, + starred: boolean, +): T[] { + return workspaces.map((w) => (w.id === id ? { ...w, starred } : w)); +} + +/** * Format an epoch-ms timestamp as a short relative string ("now", "3m", "2h", * "5d", or a date). Pure: `now` + `then` in, string out. Future timestamps * (a workspace just created) read as "now". |
