diff --git a/web/src/activity.test.ts b/web/src/activity.test.ts new file mode 100644 index 0000000..206c97f --- /dev/null +++ b/web/src/activity.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; + +import { + activityColorSlug, + activityDurationLabel, + activityHeldMs, + showsMetaLine, +} from "./activity.js"; +import type { Session } from "./types.js"; + +describe("activityHeldMs", () => { + it("measures time spent in the current state", () => { + const now = Date.now(); + const since = new Date(now - 90_000).toISOString(); + expect(activityHeldMs(since, now)).toBe(90_000); + }); + + it("returns null for payloads that predate #102 (epoch default)", () => { + expect(activityHeldMs(undefined, Date.now())).toBeNull(); + expect(activityHeldMs(new Date(0).toISOString(), Date.now())).toBeNull(); + }); + + it("returns null for unparseable timestamps", () => { + expect(activityHeldMs("not-a-time", Date.now())).toBeNull(); + }); + + it("never goes negative", () => { + const now = Date.now(); + expect(activityHeldMs(new Date(now + 5_000).toISOString(), now)).toBe(0); + }); +}); + +describe("activityDurationLabel (#102)", () => { + it("floors whole seconds under a minute", () => { + expect(activityDurationLabel(30_400)).toBe("30s"); + expect(activityDurationLabel(59_900)).toBe("59s"); + }); + + it("floors whole minutes under an hour", () => { + expect(activityDurationLabel(60_000)).toBe("1m"); + expect(activityDurationLabel(4 * 60_000 + 59_999)).toBe("4m"); + }); + + it("floors whole hours from an hour up, with no days unit", () => { + expect(activityDurationLabel(60 * 60_000)).toBe("1h"); + expect(activityDurationLabel(2 * 3_600_000 + 59 * 60_000 + 59_999)).toBe("2h"); + expect(activityDurationLabel(26 * 3_600_000)).toBe("26h"); + }); +}); + +describe("activityColorSlug", () => { + it("maps each known state to its contract slug", () => { + expect(activityColorSlug("Active")).toBe("active"); + expect(activityColorSlug("Idle")).toBe("idle"); + expect(activityColorSlug("AwaitingInput")).toBe("awaiting-input"); + expect(activityColorSlug("Error")).toBe("error"); + }); + + it("treats an unknown variant as idle so rendering never fails", () => { + expect(activityColorSlug("Teleported")).toBe("idle"); + }); +}); + +function session(overrides: Partial): Session { + return { + id: "11111111-2222-3333-4444-555555555555", + cwd: "/home/naps/tea/maestro", + command: "claude", + env: {}, + metadata: {}, + status: "Running", + created_at: "2026-08-24T10:00:00Z", + activity: "Active", + ...overrides, + }; +} + +describe("showsMetaLine", () => { + const fresh = new Date(Date.now() - 10_000).toISOString(); + + it("stays hidden for a freshly-active session", () => { + expect(showsMetaLine(session({ activity_since: fresh }))).toBe(false); + }); + + it("appears once the state has been held past 30 seconds", () => { + const held = new Date(Date.now() - 31_000).toISOString(); + expect(showsMetaLine(session({ activity_since: held }))).toBe(true); + }); + + it("appears for blocked work, exited sessions and reported errors", () => { + expect( + showsMetaLine(session({ activity_since: fresh, blocked_work: { uncommitted_files: 1 } })), + ).toBe(true); + expect(showsMetaLine(session({ activity_since: fresh, status: "Dead" }))).toBe(true); + expect(showsMetaLine(session({ activity_since: fresh, activity: "Error" }))).toBe(true); + }); + + it("needs no activity timestamp when another trigger holds", () => { + expect(showsMetaLine(session({ activity_since: undefined, status: "Dead" }))).toBe(true); + }); +}); diff --git a/web/src/activity.ts b/web/src/activity.ts new file mode 100644 index 0000000..2d3aa83 --- /dev/null +++ b/web/src/activity.ts @@ -0,0 +1,56 @@ +// docs/SIDEBAR.md: the session card's meta line — activity colour mapping and +// the shared duration format. Both clients compute it identically; the TUI +// mirrors this in crates/maestro-tui. + +import type { ActivityState, IsoTimestamp, Session } from "./types.js"; +import { sessionBlockers } from "./types.js"; + +/** A state held shorter than this keeps the card to one line (contract rule 1). */ +const META_LINE_MIN_HOLD_MS = 30_000; + +// Payloads predating #102 default `activity_since` to the Unix epoch; anything +// implausibly old is treated as "never observed" rather than a huge duration. +const PLAUSIBLE_EPOCH_MS = Date.UTC(2010, 0, 1); + +export function activityHeldMs(since: IsoTimestamp | undefined, now: number): number | null { + if (!since) return null; + const t = Date.parse(since); + if (Number.isNaN(t) || t < PLAUSIBLE_EPOCH_MS) return null; + return Math.max(0, now - t); +} + +/** `<60s` → whole seconds, `<60m` → whole minutes, else whole hours. Floored + * everywhere so the counter never shows a duration the state hasn't reached. */ +export function activityDurationLabel(heldMs: number): string { + const s = Math.floor(heldMs / 1000); + if (s < 60) return `${s}s`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m`; + return `${Math.floor(m / 60)}h`; +} + +/** CSS hook for the state's colour (`styles.css` maps slugs to tokens). An + * unknown variant degrades to idle so rendering never fails (Precedence). */ +export function activityColorSlug(state: ActivityState | string): string { + switch (state) { + case "Active": + return "active"; + case "AwaitingInput": + return "awaiting-input"; + case "Error": + return "error"; + default: + return "idle"; + } +} + +/** Line 2 renders only when at least one contract trigger holds. */ +export function showsMetaLine(s: Session, now: number = Date.now()): boolean { + const held = activityHeldMs(s.activity_since, now); + return ( + (held !== null && held >= META_LINE_MIN_HOLD_MS) || + sessionBlockers(s) !== null || + s.status === "Dead" || + s.activity === "Error" + ); +}