diff options
Diffstat (limited to 'src/features/workspaces/logic')
| -rw-r--r-- | src/features/workspaces/logic/route.test.ts | 77 | ||||
| -rw-r--r-- | src/features/workspaces/logic/route.ts | 63 | ||||
| -rw-r--r-- | src/features/workspaces/logic/view-model.test.ts | 179 | ||||
| -rw-r--r-- | src/features/workspaces/logic/view-model.ts | 71 |
4 files changed, 390 insertions, 0 deletions
diff --git a/src/features/workspaces/logic/route.test.ts b/src/features/workspaces/logic/route.test.ts new file mode 100644 index 0000000..96e0ff3 --- /dev/null +++ b/src/features/workspaces/logic/route.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_WORKSPACE_ID, + isValidSlug, + parsePath, + WORKSPACE_SLUG_RE, + workspacePath, +} from "./route"; + +describe("parsePath", () => { + it("treats the root path as home", () => { + expect(parsePath("/")).toEqual({ kind: "home" }); + expect(parsePath("")).toEqual({ kind: "home" }); + }); + + it("trims surrounding slashes", () => { + expect(parsePath("//")).toEqual({ kind: "home" }); + expect(parsePath("/my-ws/")).toEqual({ kind: "workspace", id: "my-ws" }); + }); + + it("parses a single segment as a workspace id", () => { + expect(parsePath("/default")).toEqual({ kind: "workspace", id: "default" }); + expect(parsePath("/my-workspace")).toEqual({ kind: "workspace", id: "my-workspace" }); + expect(parsePath("/ws1")).toEqual({ kind: "workspace", id: "ws1" }); + }); + + it("takes only the first segment of a deeper path", () => { + expect(parsePath("/foo/bar")).toEqual({ kind: "workspace", id: "foo" }); + expect(parsePath("/foo/bar/baz")).toEqual({ kind: "workspace", id: "foo" }); + }); + + it("URL-decodes the segment", () => { + expect(parsePath("/my%20ws")).toEqual({ kind: "workspace", id: "my ws" }); + }); + + it("does not validate the slug — an invalid id is still a workspace route", () => { + expect(parsePath("/UPPER")).toEqual({ kind: "workspace", id: "UPPER" }); + expect(parsePath("/has space")).toEqual({ kind: "workspace", id: "has space" }); + }); +}); + +describe("isValidSlug", () => { + it("accepts lowercase alphanumeric + internal hyphens", () => { + expect(isValidSlug("default")).toBe(true); + expect(isValidSlug("my-workspace")).toBe(true); + expect(isValidSlug("a")).toBe(true); + expect(isValidSlug("ws-1")).toBe(true); + }); + + it("accepts up to 40 chars", () => { + expect(isValidSlug("a".repeat(40))).toBe(true); + }); + + it("rejects empty and too-long", () => { + expect(isValidSlug("")).toBe(false); + expect(isValidSlug("a".repeat(41))).toBe(false); + }); + + it("rejects uppercase, spaces, and leading/trailing hyphens", () => { + expect(isValidSlug("MyWS")).toBe(false); + expect(isValidSlug("has space")).toBe(false); + expect(isValidSlug("-leading")).toBe(false); + expect(isValidSlug("trailing-")).toBe(false); + expect(isValidSlug("double--hyphen")).toBe(true); // internal doubles are allowed by the regex + }); + + it("WORKSPACE_SLUG_RE matches the default id", () => { + expect(WORKSPACE_SLUG_RE.test(DEFAULT_WORKSPACE_ID)).toBe(true); + }); +}); + +describe("workspacePath", () => { + it("builds the URL path for a workspace id", () => { + expect(workspacePath("default")).toBe("/default"); + expect(workspacePath("my-ws")).toBe("/my-ws"); + }); +}); diff --git a/src/features/workspaces/logic/route.ts b/src/features/workspaces/logic/route.ts new file mode 100644 index 0000000..015c6d6 --- /dev/null +++ b/src/features/workspaces/logic/route.ts @@ -0,0 +1,63 @@ +/** + * Pure routing logic for the workspaces feature — zero DOM, zero effects, zero Svelte. + * + * The app is URL-driven: the root path `/` is the workspaces HOME (lists all + * workspaces); a single path segment `/<id>` opens the workspace with that id + * (the slug). This module holds the pure mapping from a pathname to a `Route`, + * plus the slug-validation rules mirrored from the backend's `PUT /workspaces/:id`. + */ + +/** + * The workspace slug regex (mirrors the backend's `PUT /workspaces/:id` + * validation): 1–40 chars, lowercase alphanumeric with internal hyphens only + * (no leading/trailing hyphen). `"default"` matches and is a valid (but + * non-deletable) id. + */ +export const WORKSPACE_SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/; + +/** A route derived from the URL path. */ +export type Route = { readonly kind: "home" } | { readonly kind: "workspace"; readonly id: string }; + +/** The reserved id of the always-present fallback workspace. */ +export const DEFAULT_WORKSPACE_ID = "default"; + +/** + * Parse a pathname into a `Route`. `/` (or empty) → home; a leading segment → + * the workspace with that id (URL-decoded, surrounding slashes trimmed). Deeper + * paths take their FIRST segment (nested routes are not used in v1). Pure: + * pathname in, route out. Does NOT validate the slug — an invalid id still + * produces a `workspace` route; the backend's ensure call rejects it (the FE + * surfaces the error). + */ +export function parsePath(pathname: string): Route { + const trimmed = pathname.replace(/^\/+|\/+$/g, ""); + if (trimmed === "") return { kind: "home" }; + const first = trimmed.split("/")[0] ?? ""; + const id = safeDecode(first); + if (id === "") return { kind: "home" }; + return { kind: "workspace", id }; +} + +/** + * Whether a slug is valid for a NEW workspace (the form the backend accepts). + * Used by the home view's "new workspace" input before navigating. + */ +export function isValidSlug(slug: string): boolean { + return WORKSPACE_SLUG_RE.test(slug); +} + +/** + * Build the URL path for a workspace id. Used when navigating to / linking a + * workspace. Pure: id in, path string out. + */ +export function workspacePath(id: string): string { + return `/${id}`; +} + +function safeDecode(segment: string): string { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +} diff --git a/src/features/workspaces/logic/view-model.test.ts b/src/features/workspaces/logic/view-model.test.ts new file mode 100644 index 0000000..44d2f31 --- /dev/null +++ b/src/features/workspaces/logic/view-model.test.ts @@ -0,0 +1,179 @@ +import type { WorkspaceEntry } from "@dispatch/wire"; +import { describe, expect, it } from "vitest"; +import { applyStarred, pageTitle, relativeTime, sortWorkspaces } from "./view-model"; + +describe("relativeTime", () => { + const now = 1_000_000_000_000; // 2001-09-09 + + it("is 'now' within a minute", () => { + expect(relativeTime(now, now)).toBe("now"); + expect(relativeTime(now - 59_000, now)).toBe("now"); + }); + + it("is minutes under an hour", () => { + expect(relativeTime(now - 5 * 60_000, now)).toBe("5m"); + expect(relativeTime(now - 59 * 60_000, now)).toBe("59m"); + }); + + it("is hours under a day", () => { + expect(relativeTime(now - 2 * 60 * 60_000, now)).toBe("2h"); + }); + + it("is days under a week", () => { + expect(relativeTime(now - 3 * 24 * 60 * 60_000, now)).toBe("3d"); + }); + + it("is a short date beyond a week", () => { + // 7+ days ago: just check it is a MM/DD string. + const s = relativeTime(now - 10 * 24 * 60 * 60_000, now); + expect(s).toMatch(/^\d{2}\/\d{2}$/); + }); +}); + +describe("pageTitle", () => { + // Minimal valid WorkspaceEntry (the irrelevant metadata is zeroed). + const ws = (id: string, title: string): WorkspaceEntry => ({ + id, + title, + defaultCwd: null, + defaultComputerId: null, + starred: false, + createdAt: 0, + lastActivityAt: 0, + conversationCount: 0, + }); + + it("is 'Dispatch' for the home route", () => { + expect(pageTitle({ kind: "home" }, [])).toBe("Dispatch"); + expect(pageTitle({ kind: "home" }, [ws("default", "Default")])).toBe("Dispatch"); + }); + + it("is 'Dispatch: {title}' for a workspace with a display title", () => { + const list = [ws("default", "Default"), ws("my-ws", "My Workspace")]; + expect(pageTitle({ kind: "workspace", id: "my-ws" }, list)).toBe("Dispatch: My Workspace"); + }); + + it("falls back to the slug (id) until the list has loaded the workspace", () => { + expect(pageTitle({ kind: "workspace", id: "pending" }, [])).toBe("Dispatch: pending"); + }); + + it("uses the id as the title when it was never customized (defaults to id)", () => { + const list = [ws("default", "default")]; + expect(pageTitle({ kind: "workspace", id: "default" }, list)).toBe("Dispatch: default"); + }); + + it("matches by id, not title", () => { + const list = [ws("a", "shared-title"), ws("b", "shared-title")]; + 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 new file mode 100644 index 0000000..b994b7c --- /dev/null +++ b/src/features/workspaces/logic/view-model.ts @@ -0,0 +1,71 @@ +/** + * Pure view-model helpers for the workspaces feature — zero DOM, zero effects. + */ + +import type { WorkspaceEntry } from "@dispatch/wire"; +import type { Route } from "./route"; + +/** + * The browser tab / page (`document.title`) text for a route. The home route + * (`/`) is "Dispatch"; a workspace route (`/<id>`) is "Dispatch: {title}", + * using the workspace's display title and falling back to the URL slug (`id`) + * until the workspace list has loaded it — the backend defaults a workspace's + * title to its id, so the slug is the correct transient value. Pure: route + + * workspaces in, string out. + */ +export function pageTitle(route: Route, workspaces: readonly WorkspaceEntry[]): string { + if (route.kind === "home") return "Dispatch"; + const ws = workspaces.find((w) => w.id === route.id); + return `Dispatch: ${ws?.title ?? route.id}`; +} + +/** + * 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". + */ +export function relativeTime(then: number, now: number): string { + const diff = now - then; + if (diff < 60_000) return "now"; + const mins = Math.floor(diff / 60_000); + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d`; + // Beyond a week: a short date (MM/DD). Uses UTC parts for determinism in tests. + const d = new Date(then); + const month = String(d.getUTCMonth() + 1).padStart(2, "0"); + const day = String(d.getUTCDate()).padStart(2, "0"); + return `${month}/${day}`; +} |
