From c8b004902966d9b378cfcae15a14f63c065a3ef0 Mon Sep 17 00:00:00 2001 From: Doni Wicaksono Date: Sat, 17 Oct 2026 22:04:52 +0700 Subject: [PATCH 1/2] feat(server): log a client interaction from the desk POST desk/pm/clients/:client/log appends one line to the client's interaction log through ws client log, checked first. Co-Authored-By: Claude Opus 5.5 --- server/desk/pm.test.ts | 13 ++++++++++++- server/desk/pm.ts | 9 +++++++++ server/index.ts | 11 ++++++++++- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/server/desk/pm.test.ts b/server/desk/pm.test.ts index 59271c6..0be093c 100644 --- a/server/desk/pm.test.ts +++ b/server/desk/pm.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { PmConflictError, clientKeys, pmAddTask, pmAlerts, pmBlock, pmCheck, pmClient, pmClients, pmComment, pmEditTask, pmRemind, pmSetStatus, pmTask, pmTasks, pmToday, snoozeAlert } from './pm.js'; +import { PmConflictError, clientKeys, pmAddTask, pmLogInteraction, pmAlerts, pmBlock, pmCheck, pmClient, pmClients, pmComment, pmEditTask, pmRemind, pmSetStatus, pmTask, pmTasks, pmToday, snoozeAlert } from './pm.js'; import type { WsInputRunner } from './ws-input.js'; /** A `ws` that answers from `replies` by its first two words, and records every call. */ @@ -130,3 +130,14 @@ describe('desk project management writes', () => { await assert.rejects(pmSetStatus(run, { scope: 'api', id: 'task_x', status: 'done', revision: REV }), PmConflictError); }); }); + +describe('client interaction log', () => { + it('appends one line through ws client log, checked first', async () => { + const calls: string[][] = []; + const run: WsInputRunner = async (args) => { calls.push(args); return 'logged acme\n'; }; + assert.deepEqual(await pmLogInteraction(run, 'acme', { text: 'Call: agreed phase 2' }), { client: 'acme' }); + assert.deepEqual(calls, [['client', 'log', 'acme', 'Call: agreed phase 2']]); + await assert.rejects(pmLogInteraction(run, 'acme', { text: ' ' }), /the entry is empty/); + await assert.rejects(pmLogInteraction(run, '../x', { text: 'x' }), /invalid client/); + }); +}); diff --git a/server/desk/pm.ts b/server/desk/pm.ts index 533e41e..517cfa9 100644 --- a/server/desk/pm.ts +++ b/server/desk/pm.ts @@ -334,3 +334,12 @@ export async function pmRemind(run: WsInputRunner, request: Record): Promise<{ client: string }> { + const client = clientKey(key); + const text = line(request.text, 'the entry', TEXT_MAX); + if (CONTROL_RE.test(text)) throw new Error('the entry contains control characters'); + await run(['client', 'log', client, text], ''); + return { client }; +} diff --git a/server/index.ts b/server/index.ts index cf557ad..9b7f269 100644 --- a/server/index.ts +++ b/server/index.ts @@ -40,7 +40,7 @@ import { TaskConflictError, addTask, setTaskBlocked, setTaskStatus } from './des import { projectDetail } from './desk/project.js'; import { PmConflictError, pmAddTask, pmAlerts, pmBlock, pmCheck, pmClient, pmClients, pmComment, pmEditTask, pmRemind, - pmSetStatus, pmTask, pmTasks, pmToday, realWsReader, snoozeAlert, + pmLogInteraction, pmSetStatus, pmTask, pmTasks, pmToday, realWsReader, snoozeAlert, } from './desk/pm.js'; import { PlanConflictError, setPlanStatus } from './desk/plans.js'; @@ -652,6 +652,15 @@ for (const [path, action] of pmWrites) { }); } +app.post(`${AGENT_API}/desk/pm/clients/:client/log`, async (c) => { + const body = await c.req.json().catch(() => ({})) as Record; + try { + return c.json(await pmLogInteraction(realWsReader(WORKSPACE_ROOT), c.req.param('client'), body)); + } catch (err) { + return c.json(pmFail(err), 400); + } +}); + // The dossier, with the client's restricted records for the operator's eyes only: this // route serves the desk, never a chat or an agent tool. app.get(`${AGENT_API}/desk/pm/clients/:client`, async (c) => { From 60ad7afb94a5f1c0fc49137edce71f92d5d58418 Mon Sep 17 00:00:00 2001 From: Doni Wicaksono Date: Sat, 17 Oct 2026 22:04:52 +0700 Subject: [PATCH 2/2] feat(desk): Backlog, Timeline, and client dossiers The Backlog ranks open work by hand per client or milestone, with each milestone's progress; a move numbers the group and writes only the ranks that change. The Timeline lays the week out from Monday with due work and reminders on their day and late work earlier; a drag moves a due date or a reminder's day. Clients shows every client's pulse, and a dossier holds the profile, working agreement, projects, the client's own work, and the interaction log - with contacts and commercial terms marked desk only. A client scope now opens its dossier. Co-Authored-By: Claude Opus 5.5 --- docs/desk/README.md | 17 +++- src/desk/DeskApp.tsx | 37 +++++++-- src/desk/desk.css | 58 ++++++++++++++ src/desk/drawers/MenuDrawer.tsx | 18 +++++ src/desk/pm/BacklogView.tsx | 119 ++++++++++++++++++++++++++++ src/desk/pm/ClientPage.tsx | 134 ++++++++++++++++++++++++++++++++ src/desk/pm/ClientsView.tsx | 50 ++++++++++++ src/desk/pm/TimelineView.tsx | 95 ++++++++++++++++++++++ src/desk/pm/pages.ts | 5 +- src/desk/pm/plan.test.ts | 44 +++++++++++ src/desk/pm/plan.ts | 85 ++++++++++++++++++++ test/ui/missions-plan.spec.ts | 118 ++++++++++++++++++++++++++++ test/ui/workspace.ts | 4 +- 13 files changed, 775 insertions(+), 9 deletions(-) create mode 100644 src/desk/pm/BacklogView.tsx create mode 100644 src/desk/pm/ClientPage.tsx create mode 100644 src/desk/pm/ClientsView.tsx create mode 100644 src/desk/pm/TimelineView.tsx create mode 100644 src/desk/pm/plan.test.ts create mode 100644 src/desk/pm/plan.ts create mode 100644 test/ui/missions-plan.spec.ts diff --git a/docs/desk/README.md b/docs/desk/README.md index d6caa9f..adfe0e8 100644 --- a/docs/desk/README.md +++ b/docs/desk/README.md @@ -39,9 +39,11 @@ loopback server. |---|---|---| | Floor | Home | Zones (clients) > rooms (projects) > agents. Zoom to a client by its name. | | Project room | A room's name on the floor | One project: stage and focus, the mission board, crew, blockers, memory, and context (see Project room). Escape or the breadcrumb goes back to the floor. | -| Menu drawer | Menu icon | Missions: Inbox, Today, Missions board. Workspace: Floor, Projects, Agents, Approvals. Project: Context, Memory, Runs, Tasks, Notes. System: Settings. | +| Menu drawer | Menu icon | Missions: Inbox, Today, Missions board, Backlog, Timeline, Clients. Workspace: Floor, Projects, Agents, Approvals. Project: Context, Memory, Runs, Tasks, Notes. System: Settings. | | Today | Menu > Missions > Today | Every client's day in one view (see Missions). Escape or the breadcrumb goes back to the floor. | | Missions board | Menu > Missions > Missions board | Every client's tasks by station, a lane per client, project, or assignee. A card opens the task drawer. | +| Backlog, Timeline | Menu > Missions | The ranked backlog with milestones; the week on a calendar. | +| Clients, a dossier | Menu > Missions > Clients | Every client's pulse; one client's profile, projects, own work, interactions, and restricted records. | | Assistant drawer | Assistant icon | Chat at workspace scope (local endpoints only, ADR-0021). | | Agent drawer | An avatar | The agent card (state, detail, approvals), the coding session (start, stop, its changes, hand back), and a chat bound to its project. | @@ -97,6 +99,18 @@ in the desk and the browser only; navigation from the menu, with views in the ma client's own work. A card shows its scope, due date (late in red, today in orange), checklist or milestone progress, who has it (an agent figure for `agent:`), and a raised hand when blocked. Drag a card along its lane to move it. +- **Backlog**: open work grouped by client or by milestone, ranked by hand - drag a row, or + move it up or down - with the milestones and their progress on top. A move numbers the + group 1..n and writes only the ranks that change; the rank is the task's own field, so + agents read the same order. +- **Timeline**: the week, Monday first, with due work and reminders on their day and late work + in an "Earlier" column. Drag an item to another day to move its due date, or a reminder's + day at the same time. +- **Clients**: every client with its projects, open and late work, alerts, and last touch. + A client's dossier holds its profile (from `client.md`), working agreement and stakeholder + roles, projects, its own open work, the interaction log (`ws client log`), and - marked + "desk only" - its contacts and commercial terms, which the host reads from disk and no + agent ever receives. - **Task drawer**: station, priority, due date, assignee, labels, blocker, checklist, the description and acceptance criteria, a comment box, and the activity - every change by a person or an agent. Every edit is one `ws task` command with the revision the drawer read; a @@ -146,6 +160,7 @@ All under `/api/agent/desk/`, behind the host's token and loopback checks. | `POST pm/tasks/edit` | `{ scope, id, revision, changes: { title?, priority?, due?, assignee?, parent?, remind_at?, recur?, kind?, rank?, labels_add?, labels_rm?, check_add?, accept_add? } }`: `ws task edit`; `none` clears | | `POST pm/tasks/status` · `comment` · `check` · `block` · `unblock` | `ws task status|comment|check|block|unblock` with the revision; 409 when it is stale | | `POST pm/remind` | `{ scope, text, at: "YYYY-MM-DD HH:MM", recur? }`: `ws remind` | +| `POST pm/clients/:client/log` | `{ text }`: `ws client log` | | `GET pm/clients` | Every client folder with a `client.md`, with its pulse from `ws client show` | | `GET pm/clients/:client` | `ws client show --json`, plus `restrictedText` (contacts, commercial) read by the host for the desk only - never a chat or an agent tool | | `POST notes` | `{ project, title, body }` -> `{ path }`. Runs `ws note -` with the body on stdin; 400 with the reason on any refusal | diff --git a/src/desk/DeskApp.tsx b/src/desk/DeskApp.tsx index e1dabe8..c2d618b 100644 --- a/src/desk/DeskApp.tsx +++ b/src/desk/DeskApp.tsx @@ -11,6 +11,10 @@ import { SettingsDialog } from '../components/settings/SettingsDialog'; import { TodayView } from './pm/TodayView'; import { MissionsView } from './pm/MissionsView'; import { TaskDrawer } from './pm/TaskDrawer'; +import { BacklogView } from './pm/BacklogView'; +import { TimelineView } from './pm/TimelineView'; +import { ClientsView } from './pm/ClientsView'; +import { ClientPage } from './pm/ClientPage'; import { usePmAlerts } from './pm/usePm'; import { DATE_ALERTS, PM_PAGE_TITLE, type PmPage } from './pm/pages'; import type { Floor, FloorAgent, FloorRoom } from './types'; @@ -43,6 +47,8 @@ export function DeskApp() { const [room, setRoom] = useState<string | null>(null); /** A project management view filling the main area (Today, ...), or null. */ const [page, setPage] = useState<PmPage | null>(null); + /** The client whose dossier fills the main area, opened from Clients or a client scope. */ + const [clientPage, setClientPage] = useState<string | null>(null); const pm = usePmAlerts(); /** The task open in its drawer, and a counter its changes bump so the views follow. */ const [task, setTask] = useState<{ scope: string; id: string } | null>(null); @@ -71,16 +77,18 @@ export function DeskApp() { const openRoom = useCallback((target: FloorRoom) => { setProject(target.key); setPage(null); + setClientPage(null); setRoom(target.key); }, [setProject]); - // A task's scope: a project opens its room; a client's own work opens the floor at that client. + // A task's scope: a project opens its room; a client's own work opens the client's dossier. const openScope = useCallback((scope: string) => { setPage(null); if (scope.startsWith('@')) { setRoom(null); - setFocusClient(scope.slice(1)); + setClientPage(scope.slice(1)); } else { + setClientPage(null); setProject(scope); setRoom(scope); } @@ -88,19 +96,20 @@ export function DeskApp() { const openPage = useCallback((next: PmPage) => { setRoom(null); + setClientPage(null); setPage(next); setDrawer(null); }, []); // Escape leaves a room or a view for the floor, unless a drawer is open: then it closes the drawer. useEffect(() => { - if (!room && !page) return; + if (!room && !page && !clientPage) return; const onKey = (event: KeyboardEvent) => { - if (event.key === 'Escape' && !drawer && !task) { setRoom(null); setPage(null); } + if (event.key === 'Escape' && !drawer && !task) { setRoom(null); setPage(null); setClientPage(null); } }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [room, page, drawer, task]); + }, [room, page, clientPage, drawer, task]); // The menu or the assistant takes the right-hand side; an open task gives way to it. useEffect(() => { @@ -157,7 +166,15 @@ export function DeskApp() { <header className="desk-bar"> <div className="desk-crumbs"> <span className="desk-mark" aria-hidden="true" /> - {page ? ( + {clientPage ? ( + <nav aria-label="Floor position" className="crumbs"> + <button type="button" onClick={() => setClientPage(null)}>All</button> + <span aria-hidden="true">/</span> + <button type="button" onClick={() => openPage('clients')}>Clients</button> + <span aria-hidden="true">/</span> + <span aria-current="location">{clientPage}</span> + </nav> + ) : page ? ( <nav aria-label="Floor position" className="crumbs"> <button type="button" onClick={() => setPage(null)}>All</button> <span aria-hidden="true">/</span> @@ -232,8 +249,16 @@ export function DeskApp() { onSnooze={pm.snooze} onOpenTask={openTask} /> + ) : clientPage ? ( + <ClientPage client={clientPage} version={alertsVersion} onOpenProject={(key) => openScope(key)} onOpenTask={openTask} /> ) : page === 'missions' ? ( <MissionsView projects={projectKeys} version={alertsVersion} onOpenTask={openTask} /> + ) : page === 'backlog' ? ( + <BacklogView projects={projectKeys} version={alertsVersion} onOpenTask={openTask} /> + ) : page === 'timeline' ? ( + <TimelineView version={alertsVersion} onOpenTask={openTask} /> + ) : page === 'clients' ? ( + <ClientsView version={alertsVersion} onOpenClient={(key) => { setPage(null); setClientPage(key); }} /> ) : floor ? ( <FloorView floor={floor} diff --git a/src/desk/desk.css b/src/desk/desk.css index 967774a..33b9411 100644 --- a/src/desk/desk.css +++ b/src/desk/desk.css @@ -1325,6 +1325,64 @@ .task-fields { grid-template-columns: 1fr; } } +/* ── Backlog, Timeline, Clients ───────────────────────────────────────── */ +.milestones { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 8px; } +.milestone { width: 100%; display: flex; flex-direction: column; gap: 6px; padding: 10px 12px; border-radius: 12px; border: 3px double var(--d-room-line); background: var(--d-bg); color: inherit; font: inherit; text-align: left; cursor: pointer; } +.milestone:hover { border-color: var(--d-accent); } +.milestone-title { display: flex; gap: 6px; align-items: center; justify-content: space-between; font-size: 13.5px; font-weight: 600; } +.milestone-bar { height: 6px; border-radius: 999px; background: var(--d-hover); overflow: hidden; } +.milestone-bar i { display: block; height: 100%; border-radius: 999px; background: var(--d-accent); } +.milestone-meta { font-size: 11.5px; color: var(--d-ink-3); } + +.backlog-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; } +.backlog-row { position: relative; display: grid; grid-template-columns: 18px minmax(0, 1fr) auto 64px 96px 120px auto; gap: 10px; align-items: center; padding: 6px 4px 6px 10px; border-bottom: 1px solid var(--d-room-line); font-size: 12.5px; cursor: grab; } +.backlog-row:last-child { border-bottom: none; } +.backlog-row::before { content: ""; position: absolute; left: 0; top: 8px; bottom: 8px; width: 3px; border-radius: 0 3px 3px 0; background: var(--d-line); } +.backlog-row.priority-high::before { background: var(--d-attn); } +.backlog-row.priority-urgent::before { background: var(--d-warn); } +.backlog-row.is-dragging { opacity: .5; } +.backlog-grip { color: var(--d-ink-3); letter-spacing: -2px; } +.backlog-title { text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.backlog-cell { color: var(--d-ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.backlog-moves { display: flex; gap: 2px; } +.backlog-moves .icon-btn { width: 24px; height: 24px; font-size: 12px; } + +.tl-nav { display: flex; gap: 6px; flex-wrap: wrap; } +.tl-grid { display: grid; grid-template-columns: repeat(8, minmax(130px, 1fr)); gap: 8px; overflow-x: auto; padding-bottom: 6px; } +.tl-day { min-height: 220px; padding: 8px; border-radius: 12px; background: var(--d-room); border: 1px solid var(--d-room-line); display: flex; flex-direction: column; gap: 6px; } +.tl-day.is-today { border: 2px solid var(--d-accent); } +.tl-day.is-earlier { background: color-mix(in srgb, var(--d-warn) 5%, var(--d-room)); border-style: dashed; } +.tl-day.is-target { border-color: var(--d-accent); } +.tl-head { margin: 0; display: flex; justify-content: space-between; align-items: baseline; font-size: 12px; font-weight: 650; color: var(--d-ink-2); } +.tl-head small { font-weight: 500; color: var(--d-ink-3); } +.tl-day ol { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; } +.tl-item { padding: 5px 7px; border-radius: 8px; background: var(--d-bg); border: 1px solid var(--d-room-line); cursor: grab; } +.tl-item.is-reminder { border-color: color-mix(in srgb, var(--d-attn) 45%, transparent); } +.tl-item.is-done { opacity: .55; } +.tl-title { font-size: 12px; color: var(--d-ink); overflow-wrap: anywhere; } +.tl-title b { color: var(--d-attn); } +.tl-scope { font-size: 10.5px; color: var(--d-ink-3); } + +.client-cards { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 10px; } +.client-card { width: 100%; display: flex; flex-direction: column; gap: 4px; padding: 14px; border-radius: 14px; text-align: left; cursor: pointer; font: inherit; color: inherit; + background: var(--d-room); border: 1px solid var(--d-room-line); border-top: 3px solid var(--d-accent); } +.client-card:hover { border-color: var(--d-accent); } +.client-card-key { font-size: 12px; letter-spacing: .14em; text-transform: uppercase; font-weight: 700; color: var(--d-ink-2); } +.client-card-name { font-size: 15px; font-weight: 600; } +.client-card-stats { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: var(--d-ink-2); } +.client-card-stats .is-warn { color: var(--d-warn); font-weight: 650; } +.client-card-stats .is-attn { color: var(--d-attn); font-weight: 650; } +.client-card-when { font-size: 11.5px; color: var(--d-ink-3); } +.client-main { display: flex; flex-direction: column; gap: 12px; min-width: 0; } +.restricted { border-style: dashed; } +.restricted-badge { margin-left: 6px; padding: 1px 7px; border-radius: 999px; font-size: 10px; letter-spacing: .08em; color: var(--d-attn); border: 1px solid color-mix(in srgb, var(--d-attn) 50%, transparent); } +.restricted-text { margin: 0; max-height: 260px; overflow: auto; white-space: pre-wrap; font-family: var(--font-mono); font-size: 11.5px; color: var(--d-ink-2); } + +@media (max-width: 900px) { + .backlog-row { grid-template-columns: 18px minmax(0, 1fr) auto auto; } + .backlog-row .backlog-cell { display: none; } +} + /* ── Small screens ───────────────────────────────────────────────────── */ @media (max-width: 720px) { .desk-main { padding: 72px 12px 64px; } diff --git a/src/desk/drawers/MenuDrawer.tsx b/src/desk/drawers/MenuDrawer.tsx index eedc610..0f624af 100644 --- a/src/desk/drawers/MenuDrawer.tsx +++ b/src/desk/drawers/MenuDrawer.tsx @@ -169,6 +169,24 @@ function Nav({ floor, project, onSection, onFloor, alerts, onPage }: Props) { <ChevronIcon /> </button> </li> + <li> + <button type="button" className="nav-item" onClick={() => onPage('backlog')}> + <span>Backlog</span> + <ChevronIcon /> + </button> + </li> + <li> + <button type="button" className="nav-item" onClick={() => onPage('timeline')}> + <span>Timeline</span> + <ChevronIcon /> + </button> + </li> + <li> + <button type="button" className="nav-item" onClick={() => onPage('clients')}> + <span>Clients</span> + <ChevronIcon /> + </button> + </li> </ul> <p className="nav-group">Workspace</p> <ul className="nav-list"> diff --git a/src/desk/pm/BacklogView.tsx b/src/desk/pm/BacklogView.tsx new file mode 100644 index 0000000..c93d77c --- /dev/null +++ b/src/desk/pm/BacklogView.tsx @@ -0,0 +1,119 @@ +import { useMemo, useState } from 'react'; +import { apiFetch } from '../../lib/api'; +import { QuickAdd } from './QuickAdd'; +import { backlogGroups, milestones, reorder } from './plan'; +import { dueTone } from './board'; +import { usePmRead } from './usePm'; +import type { PmClientPulse, PmTask } from './types'; + +interface Props { + projects: string[]; + version: string; + onOpenTask: (scope: string, id: string) => void; +} + +function localDay(): string { + const now = new Date(); + return new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 10); +} + +/** + * Everything not yet done, ranked by hand, per client or per milestone, with the milestones + * and how far each has come. The rank is a field of the task, so agents read the same order. + */ +export function BacklogView({ projects, version, onOpenTask }: Props) { + const [reloads, setReloads] = useState(0); + const { data, error } = usePmRead<{ tasks: PmTask[] }>('desk/pm/tasks', `${version}:${reloads}`); + const clients = usePmRead<{ clients: PmClientPulse[] }>('desk/pm/clients', version); + const [by, setBy] = useState<'client' | 'milestone'>('client'); + const [dragging, setDragging] = useState<{ id: string; group: string } | null>(null); + const [moveError, setMoveError] = useState(''); + const today = localDay(); + const tasks = useMemo(() => data?.tasks ?? [], [data]); + const groups = useMemo(() => backlogGroups(tasks, by), [tasks, by]); + const stones = useMemo(() => milestones(tasks), [tasks]); + + const move = async (group: PmTask[], id: string, index: number) => { + try { + // One edit per task whose rank changes, each with the revision the list read. + for (const { task, rank } of reorder(group, id, index)) { + await apiFetch('desk/pm/tasks/edit', { method: 'POST', body: JSON.stringify({ scope: task.scope, id: task.id, revision: task.revision, changes: { rank } }) }); + } + setMoveError(''); + } catch (err) { + setMoveError(err instanceof Error ? err.message : String(err)); + } finally { + setReloads((n) => n + 1); + } + }; + + return ( + <div className="pm-view" aria-label="Backlog"> + <header className="pm-head"> + <div> + <p className="pm-kicker">Missions</p> + <h1 className="pm-title">Backlog</h1> + </div> + <span className="pm-date">{groups.reduce((n, g) => n + g.tasks.length, 0)} open, ranked by hand</span> + </header> + <div className="board-tools"> + <QuickAdd projects={projects} clients={(clients.data?.clients ?? []).map((c) => c.key)} onAdded={() => setReloads((n) => n + 1)} /> + <div className="board-filters"> + <select aria-label="Group by" value={by} onChange={(e) => setBy(e.target.value as 'client' | 'milestone')}> + <option value="client">Group: client</option> + <option value="milestone">Group: milestone</option> + </select> + </div> + </div> + {moveError && <p className="board-error" role="alert">[ERROR] {moveError}</p>} + {error && !data && <p className="board-error" role="alert">[ERROR] {error}</p>} + + {stones.length > 0 && ( + <section className="panel" aria-label="Milestones"> + <h2 className="panel-title">Milestones</h2> + <ul className="milestones"> + {stones.map((m) => { + const progress = m.progress ?? { done: 0, total: 0 }; + const share = progress.total ? Math.round((progress.done / progress.total) * 100) : 0; + return ( + <li key={`${m.scope}:${m.id}`}> + <button type="button" className="milestone" onClick={() => onOpenTask(m.scope, m.id)} aria-label={`Milestone: ${m.title}`}> + <span className="milestone-title">{m.title}<span className="chip">{m.scope}</span></span> + <span className="milestone-bar" aria-hidden="true"><i style={{ width: `${share}%` }} /></span> + <span className="milestone-meta">{progress.done}/{progress.total} done{m.due ? ` · due ${m.due}` : ''}</span> + </button> + </li> + ); + })} + </ul> + </section> + )} + + {!data ? <p className="desk-loading" role="status">Reading the backlog...</p> : groups.map((group) => ( + <section key={group.key || '-'} className="panel backlog-group" aria-label={`Backlog ${group.label}`}> + <h2 className="panel-title">{group.label} <span className="panel-sub">· {group.tasks.length}</span></h2> + <ol className="backlog-list"> + {group.tasks.map((task, index) => ( + <li key={`${task.scope}:${task.id}`} draggable aria-label={`Rank ${index + 1}: ${task.title}`} + className={`backlog-row priority-${task.priority}${dragging?.id === task.id ? ' is-dragging' : ''}`} + onDragStart={() => setDragging({ id: task.id, group: group.key })} onDragEnd={() => setDragging(null)} + onDragOver={(e) => { if (dragging?.group === group.key) e.preventDefault(); }} + onDrop={(e) => { e.preventDefault(); if (dragging?.group === group.key) void move(group.tasks, dragging.id, index); setDragging(null); }}> + <span className="backlog-grip" aria-hidden="true">::</span> + <button type="button" className="mission-link is-plain backlog-title" onClick={() => onOpenTask(task.scope, task.id)}>{task.title}</button> + <span className="chip">{task.scope}</span> + <span className="backlog-cell">{task.priority}</span> + <span className={`backlog-cell mission-due is-${dueTone(task, today) || 'later'}`}>{task.due || '-'}</span> + <span className="backlog-cell">{task.assignee || '-'}</span> + <span className="backlog-moves"> + <button type="button" className="icon-btn" disabled={index === 0} aria-label={`Move ${task.title} up`} onClick={() => void move(group.tasks, task.id, index - 1)}>^</button> + <button type="button" className="icon-btn" disabled={index === group.tasks.length - 1} aria-label={`Move ${task.title} down`} onClick={() => void move(group.tasks, task.id, index + 1)}>v</button> + </span> + </li> + ))} + </ol> + </section> + ))} + </div> + ); +} diff --git a/src/desk/pm/ClientPage.tsx b/src/desk/pm/ClientPage.tsx new file mode 100644 index 0000000..857b775 --- /dev/null +++ b/src/desk/pm/ClientPage.tsx @@ -0,0 +1,134 @@ +import { useState } from 'react'; +import { apiFetch } from '../../lib/api'; +import { usePmRead } from './usePm'; +import type { PmTask } from './types'; + +interface Dossier { + client: string; + profile: { title: string; fields: Record<string, string>; about: string; working_agreement: string; stakeholders: string }; + projects: Array<{ key: string; open: number; overdue: number }>; + tasks: PmTask[]; + open: number; + overdue: number; + alerts: number; + interactions: Array<{ at: string; actor: string; text: string }>; + restrictedText: { contacts: string | null; commercial: string | null }; +} + +interface Props { + client: string; + version: string; + onOpenProject: (key: string) => void; + onOpenTask: (scope: string, id: string) => void; +} + +/** + * One client's dossier: who they are and how to work with them, their projects and their + * own open work, the interaction log, and - for the operator only - their contacts and + * commercial terms. Those two never reach an agent (ADR-0024): no `ws` command prints them. + */ +export function ClientPage({ client, version, onOpenProject, onOpenTask }: Props) { + const [reloads, setReloads] = useState(0); + const { data, error } = usePmRead<Dossier>(`desk/pm/clients/${encodeURIComponent(client)}`, `${version}:${reloads}`); + const [entry, setEntry] = useState(''); + const [logError, setLogError] = useState(''); + + const log = async () => { + if (!entry.trim()) { setLogError('an entry needs text'); return; } + try { + await apiFetch(`desk/pm/clients/${encodeURIComponent(client)}/log`, { method: 'POST', body: JSON.stringify({ text: entry }) }); + setEntry(''); + setLogError(''); + setReloads((n) => n + 1); + } catch (err) { + setLogError(err instanceof Error ? err.message : String(err)); + } + }; + + if (error && !data) return <p className="desk-loading" role="alert">[ERROR] {error}</p>; + if (!data) return <p className="desk-loading" role="status">Opening {client}...</p>; + const fields = Object.entries(data.profile.fields).filter(([, value]) => value); + + return ( + <div className="pm-view" aria-label={`Client ${client}`}> + <header className="room-hero"> + <div> + <p className="room-hero-client">Client</p> + <h1 className="room-hero-key">{client}</h1> + {data.profile.about && <p className="room-hero-desc">{data.profile.about}</p>} + </div> + <dl className="room-hero-facts"> + {fields.map(([name, value]) => <div key={name}><dt>{name}</dt><dd>{value}</dd></div>)} + </dl> + <div className="room-chips"> + <span className="chip">{data.projects.length} {data.projects.length === 1 ? 'project' : 'projects'}</span> + <span className="chip">{data.open} open</span> + <span className={`chip${data.overdue ? ' is-attn' : ''}`}>{data.overdue} late</span> + <span className="chip">{data.alerts} alerts</span> + </div> + </header> + + <div className="room-grid"> + <div className="client-main"> + <section className="panel" aria-label="Projects"> + <h2 className="panel-title">Projects</h2> + {data.projects.length === 0 ? <p className="panel-empty">No project registered for this client.</p> : ( + <ul className="memory-list"> + {data.projects.map((p) => ( + <li key={p.key}> + <button type="button" className="mission-link" onClick={() => onOpenProject(p.key)}>{p.key}</button> + <span className="memory-date"> · {p.open} open{p.overdue ? `, ${p.overdue} late` : ''}</span> + </li> + ))} + </ul> + )} + </section> + <section className="panel" aria-label="Their own work"> + <h2 className="panel-title">Their own work <span className="panel-sub">· follow-ups, reports, invoices</span></h2> + {data.tasks.length === 0 ? <p className="panel-empty">Nothing open for the client itself.</p> : ( + <ul className="memory-list"> + {data.tasks.map((t) => ( + <li key={t.id}> + <button type="button" className="mission-link is-plain" onClick={() => onOpenTask(t.scope, t.id)}>{t.title}</button> + <span className="memory-date">{t.due ? ` · due ${t.due}` : ''} · {t.status.replace('_', ' ')}</span> + </li> + ))} + </ul> + )} + </section> + <section className="panel" aria-label="Interactions"> + <h2 className="panel-title">Interactions</h2> + <form className="task-inline" onSubmit={(e) => { e.preventDefault(); void log(); }}> + <input value={entry} onChange={(e) => setEntry(e.target.value)} placeholder="Call: agreed the phase 2 scope" aria-label="New interaction" /> + <button type="submit" className="btn btn-primary">Log</button> + </form> + {logError && <p className="board-error" role="alert">[ERROR] {logError}</p>} + {data.interactions.length === 0 ? <p className="panel-empty">No interaction logged yet.</p> : ( + <ol className="task-activity"> + {data.interactions.map((i, n) => <li key={n}><span className="memory-date">{i.at.slice(0, 16).replace('T', ' ')}</span> <b>{i.actor}</b> {i.text}</li>)} + </ol> + )} + </section> + </div> + + <aside className="room-side"> + {(data.profile.working_agreement || data.profile.stakeholders) && ( + <section className="panel" aria-label="Working with them"> + <h2 className="panel-title">Working with them</h2> + {data.profile.working_agreement && <p className="task-description">{data.profile.working_agreement}</p>} + {data.profile.stakeholders && <><h3 className="panel-sub-title">Stakeholders</h3><p className="task-description">{data.profile.stakeholders}</p></>} + </section> + )} + {(['contacts', 'commercial'] as const).map((name) => ( + <section key={name} className="panel restricted" aria-label={name === 'contacts' ? 'Contacts' : 'Commercial terms'}> + <h2 className="panel-title">{name === 'contacts' ? 'Contacts' : 'Commercial terms'} <span className="restricted-badge">desk only</span></h2> + {data.restrictedText[name] + ? <pre className="restricted-text">{data.restrictedText[name]}</pre> + : <p className="panel-empty">None yet. Add <code>context/clients/{client}/{name}.md</code>; it never reaches an agent.</p>} + </section> + ))} + </aside> + </div> + </div> + ); +} diff --git a/src/desk/pm/ClientsView.tsx b/src/desk/pm/ClientsView.tsx new file mode 100644 index 0000000..547387a --- /dev/null +++ b/src/desk/pm/ClientsView.tsx @@ -0,0 +1,50 @@ +import { usePmRead } from './usePm'; +import type { PmClientPulse } from './types'; +import { ago } from '../time'; + +interface Props { + version: string; + onOpenClient: (key: string) => void; +} + +function since(iso: string): string { + if (!iso) return 'no interaction yet'; + const age = ago(iso); + return age === 'now' ? 'touched today' : `touched ${age} ago`; +} + +/** Every client at a glance - open work, what is late, what alerts - each opening its dossier. */ +export function ClientsView({ version, onOpenClient }: Props) { + const { data, error } = usePmRead<{ clients: PmClientPulse[] }>('desk/pm/clients', version); + return ( + <div className="pm-view" aria-label="Clients"> + <header className="pm-head"> + <div> + <p className="pm-kicker">Missions</p> + <h1 className="pm-title">Clients</h1> + </div> + <span className="pm-date">{data ? `${data.clients.length} clients` : ''}</span> + </header> + {error && !data && <p className="board-error" role="alert">[ERROR] {error}</p>} + {!data ? <p className="desk-loading" role="status">Reading clients...</p> : ( + <ul className="client-cards"> + {data.clients.map((client) => ( + <li key={client.key}> + <button type="button" className="client-card" onClick={() => onOpenClient(client.key)} aria-label={`Client ${client.key}`}> + <span className="client-card-key">{client.key}</span> + {client.name && <span className="client-card-name">{client.name}</span>} + <span className="client-card-stats"> + <span>{client.projects} {client.projects === 1 ? 'project' : 'projects'}</span> + <span>{client.open} open</span> + {client.overdue > 0 && <span className="is-warn">{client.overdue} late</span>} + {client.alerts > 0 && <span className="is-attn">{client.alerts} alerts</span>} + </span> + <span className="client-card-when">{client.error ? `[ERROR] ${client.error}` : since(client.lastInteraction)}</span> + </button> + </li> + ))} + </ul> + )} + </div> + ); +} diff --git a/src/desk/pm/TimelineView.tsx b/src/desk/pm/TimelineView.tsx new file mode 100644 index 0000000..57601b4 --- /dev/null +++ b/src/desk/pm/TimelineView.tsx @@ -0,0 +1,95 @@ +import { useMemo, useState } from 'react'; +import { apiFetch } from '../../lib/api'; +import { nextWeek, timeline, weekOf } from './plan'; +import { usePmRead } from './usePm'; +import type { PmTask } from './types'; + +interface Props { + version: string; + onOpenTask: (scope: string, id: string) => void; +} + +function localDay(): string { + const now = new Date(); + return new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 10); +} + +function label(day: string): { weekday: string; date: string } { + const date = new Date(`${day}T12:00:00`); + return { weekday: date.toLocaleDateString(undefined, { weekday: 'short' }), date: date.toLocaleDateString(undefined, { day: 'numeric', month: 'short' }) }; +} + +/** + * The week on a calendar: due work and reminders on their day, what is late before it. Drag + * an item to another day to move its due date, or a reminder's day at the same time. + */ +export function TimelineView({ version, onOpenTask }: Props) { + const today = localDay(); + const [anchor, setAnchor] = useState(today); + const [reloads, setReloads] = useState(0); + const [dragging, setDragging] = useState<PmTask | null>(null); + const [moveError, setMoveError] = useState(''); + const { data, error } = usePmRead<{ tasks: PmTask[] }>('desk/pm/tasks', `${version}:${reloads}`); + const days = useMemo(() => weekOf(anchor), [anchor]); + const placed = useMemo(() => timeline(data?.tasks ?? [], days), [data, days]); + + const moveTo = async (task: PmTask, day: string) => { + const changes = task.remind_at ? { remind_at: `${day} ${task.remind_at.slice(11, 16)}` } : { due: day }; + try { + await apiFetch('desk/pm/tasks/edit', { method: 'POST', body: JSON.stringify({ scope: task.scope, id: task.id, revision: task.revision, changes }) }); + setMoveError(''); + } catch (err) { + setMoveError(err instanceof Error ? err.message : String(err)); + } finally { + setReloads((n) => n + 1); + } + }; + + const item = (task: PmTask) => ( + <li key={`${task.scope}:${task.id}`} draggable onDragStart={() => setDragging(task)} onDragEnd={() => setDragging(null)} + className={`tl-item${task.kind === 'reminder' ? ' is-reminder' : ''}${task.status === 'done' ? ' is-done' : ''}`}> + <button type="button" className="mission-open" onClick={() => onOpenTask(task.scope, task.id)} + aria-label={`${task.kind === 'reminder' ? 'Reminder' : 'Due'}: ${task.title}`}> + <span className="tl-title">{task.remind_at && <b>{task.remind_at.slice(11, 16)} </b>}{task.title}</span> + <span className="tl-scope">{task.scope}</span> + </button> + </li> + ); + + return ( + <div className="pm-view" aria-label="Timeline"> + <header className="pm-head"> + <div> + <p className="pm-kicker">Missions</p> + <h1 className="pm-title">Timeline</h1> + </div> + <div className="tl-nav" role="group" aria-label="Week"> + <button type="button" className="btn" onClick={() => setAnchor(nextWeek(anchor, -1))}>Previous week</button> + <button type="button" className="btn" onClick={() => setAnchor(today)}>This week</button> + <button type="button" className="btn" onClick={() => setAnchor(nextWeek(anchor, 1))}>Next week</button> + </div> + </header> + {moveError && <p className="board-error" role="alert">[ERROR] {moveError}</p>} + {error && !data && <p className="board-error" role="alert">[ERROR] {error}</p>} + {!data ? <p className="desk-loading" role="status">Reading dates...</p> : ( + <div className="tl-grid"> + <section className="tl-day is-earlier" aria-label="Earlier, not done"> + <h2 className="tl-head"><span>Earlier</span><small>late</small></h2> + <ol>{placed.earlier.map(item)}</ol> + </section> + {days.map((day) => { + const { weekday, date } = label(day); + return ( + <section key={day} aria-label={`${weekday} ${day}`} className={`tl-day${day === today ? ' is-today' : ''}${dragging ? ' is-target' : ''}`} + onDragOver={(e) => { if (dragging) e.preventDefault(); }} + onDrop={(e) => { e.preventDefault(); if (dragging) void moveTo(dragging, day); setDragging(null); }}> + <h2 className="tl-head"><span>{weekday}</span><small>{date}</small></h2> + <ol>{placed.days[day].map(item)}</ol> + </section> + ); + })} + </div> + )} + </div> + ); +} diff --git a/src/desk/pm/pages.ts b/src/desk/pm/pages.ts index 8711e46..007a8cf 100644 --- a/src/desk/pm/pages.ts +++ b/src/desk/pm/pages.ts @@ -1,9 +1,12 @@ /** Project management views that fill the main area, opened from the menu (ADR-0024, owner's D4). */ -export type PmPage = 'today' | 'missions'; +export type PmPage = 'today' | 'missions' | 'backlog' | 'timeline' | 'clients'; export const PM_PAGE_TITLE: Record<PmPage, string> = { today: 'Today', missions: 'Missions board', + backlog: 'Backlog', + timeline: 'Timeline', + clients: 'Clients', }; /** Alerts the floor does not already show: dates and reminders (approvals and hands are on the floor). */ diff --git a/src/desk/pm/plan.test.ts b/src/desk/pm/plan.test.ts new file mode 100644 index 0000000..20340be --- /dev/null +++ b/src/desk/pm/plan.test.ts @@ -0,0 +1,44 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { backlogGroups, milestones, reorder, timeline, weekOf } from './plan'; +import type { PmTask } from './types'; + +function task(id: string, fields: Partial<PmTask> = {}): PmTask { + return { + id, title: id, scope: 'api', project: 'api', client: 'acme', status: 'backlog', priority: 'normal', kind: 'task', owner: 'dw', + assignee: '', due: '', remind_at: '', recur: '', rank: 0, parent: '', depends_on: [], labels: [], acceptance_criteria: [], + blocked: false, blocked_reason: '', description: '', checklist: [], activity: [], revision: 'r', updated_at: '', created_at: '', ...fields, + }; +} + +describe('backlog and timeline', () => { + it('groups open work by client or milestone, ranked, milestones and done work left out', () => { + const tasks = [ + task('task_b', { client: 'beta', rank: 2 }), task('task_a', { client: 'beta', rank: 1 }), task('task_c', { client: 'acme', due: '2026-10-20' }), + task('task_m', { kind: 'milestone', title: 'Release 1' }), task('task_x', { parent: 'task_m' }), task('task_done', { status: 'done' }), + ]; + assert.deepEqual(backlogGroups(tasks, 'client').map((g) => [g.label, g.tasks.map((t) => t.id)]), + [['acme', ['task_c', 'task_x']], ['beta', ['task_a', 'task_b']]]); + assert.deepEqual(backlogGroups(tasks, 'milestone').map((g) => g.label), ['Release 1', 'no milestone']); + assert.deepEqual(milestones(tasks).map((t) => t.id), ['task_m']); + }); + + it('renumbers a group to put a task where it was dropped, writing only what changes', () => { + const group = [task('a', { rank: 1 }), task('b', { rank: 2 }), task('c', { rank: 3 })]; + assert.deepEqual(reorder(group, 'c', 0).map(({ task: t, rank }) => [t.id, rank]), [['c', 1], ['a', 2], ['b', 3]]); + assert.deepEqual(reorder(group, 'a', 1).map(({ task: t, rank }) => [t.id, rank]), [['b', 1], ['a', 2]]); + assert.deepEqual(reorder(group, 'b', 1), []); + }); + + it('lays a week out from Monday, with reminders on their day and late work earlier', () => { + const days = weekOf('2026-10-17'); + assert.deepEqual([days[0], days[6]], ['2026-10-12', '2026-10-18']); + const placed = timeline([ + task('task_due', { due: '2026-10-14' }), task('task_call', { kind: 'reminder', remind_at: '2026-10-16T14:00+07:00', due: '2026-10-16' }), + task('task_late', { due: '2026-10-01' }), task('task_old_done', { due: '2026-10-01', status: 'done' }), task('task_none'), + ], days); + assert.deepEqual(placed.days['2026-10-14'].map((t) => t.id), ['task_due']); + assert.deepEqual(placed.days['2026-10-16'].map((t) => t.id), ['task_call']); + assert.deepEqual(placed.earlier.map((t) => t.id), ['task_late']); + }); +}); diff --git a/src/desk/pm/plan.ts b/src/desk/pm/plan.ts new file mode 100644 index 0000000..07c9f14 --- /dev/null +++ b/src/desk/pm/plan.ts @@ -0,0 +1,85 @@ +import type { PmTask } from './types'; + +/** + * The Backlog and the Timeline (ADR-0024: a ranked backlog with due dates and milestones, + * no sprints). Pure helpers, tested without a page. + */ + +/** Ranked work first (lower rank earlier), then by due date, then by id. */ +export function byRank(a: PmTask, b: PmTask): number { + return (a.rank === 0 ? 1 : 0) - (b.rank === 0 ? 1 : 0) || a.rank - b.rank + || (a.due || '9999').localeCompare(b.due || '9999') || a.id.localeCompare(b.id); +} + +export interface BacklogGroup { + key: string; + label: string; + tasks: PmTask[]; +} + +/** Open work that is not a milestone, grouped by client or by milestone, each group ranked. */ +export function backlogGroups(tasks: PmTask[], by: 'client' | 'milestone'): BacklogGroup[] { + const open = tasks.filter((t) => t.status !== 'done' && t.kind !== 'milestone'); + const titles = new Map(tasks.filter((t) => t.kind === 'milestone').map((t) => [t.id, t.title])); + const groups = new Map<string, PmTask[]>(); + for (const task of open) { + const key = by === 'client' ? task.client : titles.has(task.parent) ? task.parent : ''; + groups.set(key, [...(groups.get(key) ?? []), task]); + } + const empty = by === 'client' ? 'no client' : 'no milestone'; + return [...groups.entries()] + .sort(([a], [b]) => (a === '' ? 1 : b === '' ? -1 : (titles.get(a) ?? a).localeCompare(titles.get(b) ?? b))) + .map(([key, group]) => ({ key, label: key ? titles.get(key) ?? key : empty, tasks: [...group].sort(byRank) })); +} + +/** + * The ranks that put `id` at `index` in `group`: every task numbered 1..n in the new order, + * returning only the ones whose rank changes, so a move costs as few writes as it can. + */ +export function reorder(group: PmTask[], id: string, index: number): Array<{ task: PmTask; rank: number }> { + const moving = group.find((t) => t.id === id); + if (!moving) return []; + const rest = group.filter((t) => t.id !== id); + const order = [...rest.slice(0, index), moving, ...rest.slice(index)]; + return order.map((task, i) => ({ task, rank: i + 1 })).filter(({ task, rank }) => task.rank !== rank); +} + +export function milestones(tasks: PmTask[]): PmTask[] { + return tasks.filter((t) => t.kind === 'milestone').sort((a, b) => (a.due || '9999').localeCompare(b.due || '9999') || a.id.localeCompare(b.id)); +} + +function shift(day: string, days: number): string { + const date = new Date(`${day}T12:00:00Z`); + date.setUTCDate(date.getUTCDate() + days); + return date.toISOString().slice(0, 10); +} + +/** The seven days of the week holding `day`, Monday first. */ +export function weekOf(day: string): string[] { + const weekday = new Date(`${day}T12:00:00Z`).getUTCDay(); + const monday = shift(day, -((weekday + 6) % 7)); + return Array.from({ length: 7 }, (_, i) => shift(monday, i)); +} + +export function nextWeek(day: string, weeks: number): string { + return shift(day, weeks * 7); +} + +/** The day a task stands on the timeline: a reminder's day, else its due date. */ +export function dayOf(task: PmTask): string { + return task.remind_at ? task.remind_at.slice(0, 10) : task.due; +} + +/** Open work placed on `days`; what fell before the first day and is not done is "earlier". */ +export function timeline(tasks: PmTask[], days: string[]): { earlier: PmTask[]; days: Record<string, PmTask[]> } { + const placed: Record<string, PmTask[]> = Object.fromEntries(days.map((d) => [d, []])); + const earlier: PmTask[] = []; + for (const task of tasks) { + const day = dayOf(task); + if (!day || task.status === 'done' && !(day in placed)) continue; + if (day in placed) placed[day].push(task); + else if (day < days[0] && task.status !== 'done') earlier.push(task); + } + for (const list of Object.values(placed)) list.sort((a, b) => (a.remind_at || '').localeCompare(b.remind_at || '') || byRank(a, b)); + return { earlier: earlier.sort((a, b) => dayOf(a).localeCompare(dayOf(b))), days: placed }; +} diff --git a/test/ui/missions-plan.spec.ts b/test/ui/missions-plan.spec.ts new file mode 100644 index 0000000..0909793 --- /dev/null +++ b/test/ui/missions-plan.spec.ts @@ -0,0 +1,118 @@ +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import type { Page } from '@playwright/test'; +import { test, expect } from './fixtures.js'; +import { PROJECT } from './workspace.js'; +import { STATE_FILE, type ServerState } from './state.js'; + +/** + * The Backlog, the Timeline, and the client dossier (Agent-Workspace ADR-0024), against the + * stub's task store and a dossier the spec writes. Dates are relative to today, so the + * Timeline's week always holds them. + */ + +const root = () => (JSON.parse(readFileSync(STATE_FILE, 'utf-8')) as ServerState).root; +const stubFile = (name: string) => join(root(), '.local', 'stub', name); +const stored = () => (JSON.parse(readFileSync(stubFile('tasks.json'), 'utf-8')) as { tasks: Array<Record<string, unknown>> }).tasks; + +function localDay(offset = 0): string { + const now = new Date(); + now.setDate(now.getDate() + offset); + return new Date(now.getTime() - now.getTimezoneOffset() * 60_000).toISOString().slice(0, 10); +} + +/** Another day of this week (Monday to Sunday) than today. */ +function otherDayThisWeek(): string { + const weekday = (new Date().getDay() + 6) % 7; // Monday 0 + return localDay(weekday === 0 ? 1 : -1); +} + +function task(id: string, fields: Record<string, unknown>) { + return { + id, title: id, scope: PROJECT, project: PROJECT, client: 'framework', status: 'backlog', priority: 'normal', kind: 'task', owner: 'dw', + assignee: '', due: '', remind_at: '', recur: '', rank: 0, parent: '', depends_on: [], labels: [], acceptance_criteria: [], + blocked: false, blocked_reason: '', description: '', checklist: [], activity: [], revision: 'mtime:1:size:1', + updated_at: '2026-10-16T10:00:00+07:00', created_at: '2026-10-01T10:00:00+07:00', ...fields, + }; +} + +test.beforeEach(() => { + mkdirSync(join(root(), '.local', 'stub'), { recursive: true }); + writeFileSync(stubFile('calls.log'), ''); + writeFileSync(stubFile('tasks.json'), JSON.stringify({ tasks: [ + task('task_first', { title: 'First in line', rank: 1, due: localDay() }), + task('task_second', { title: 'Second in line', rank: 2 }), + task('task_third', { title: 'Third in line', rank: 3, due: '2020-01-02', parent: 'task_release' }), + task('task_release', { title: 'Release one', kind: 'milestone', due: '2099-12-01', progress: { done: 1, total: 2 } }), + task('task_call', { title: 'Call about phase 2', kind: 'reminder', scope: '@framework', project: '', remind_at: `${localDay()}T14:00+07:00`, due: localDay() }), + ] })); + writeFileSync(stubFile('client-framework.json'), JSON.stringify({ + client: 'framework', open: 3, overdue: 1, alerts: 2, + profile: { title: 'framework - client', fields: { 'Full name': 'Framework Holdings' }, about: 'A fixture client.', working_agreement: 'Reports on Friday.', stakeholders: 'Head of IT decides.' }, + projects: [{ key: PROJECT, open: 3, overdue: 1 }], + tasks: [task('task_call', { title: 'Call about phase 2', scope: '@framework', project: '' })], + interactions: [{ at: '2026-10-16T10:00:00+07:00', actor: 'dw-desk', text: 'Kick-off call' }], + })); + writeFileSync(join(root(), 'context', 'clients', 'framework', 'contacts.md'), '| Ana | Head of IT | chat |\n'); +}); + +test.afterAll(() => { + rmSync(stubFile('tasks.json'), { force: true }); + rmSync(join(root(), 'context', 'clients', 'framework', 'contacts.md'), { force: true }); +}); + +async function open(page: Page, deskUrl: string, entry: string) { + await page.goto(deskUrl); + await page.getByRole('button', { name: /^Menu/ }).click(); + await page.getByRole('dialog', { name: 'Menu' }).getByRole('button', { name: entry, exact: true }).click(); + await expect(page.getByRole('heading', { level: 1, name: entry })).toBeVisible(); +} + +test('the backlog is ranked by hand, and a move rewrites only the ranks that change', async ({ page, deskUrl }) => { + await open(page, deskUrl, 'Backlog'); + const group = page.getByRole('region', { name: 'Backlog framework' }); + await expect(group.getByRole('listitem')).toHaveText([/First in line/, /Second in line/, /Third in line/, /Call about phase 2/]); + await expect(page.getByRole('region', { name: 'Milestones' }).getByRole('button', { name: 'Milestone: Release one' })).toContainText('1/2 done'); + + await group.getByRole('button', { name: 'Move Third in line up' }).click(); + await expect.poll(() => stored().map((t) => [t.id, t.rank]).filter(([, rank]) => rank)).toEqual( + // The whole group is numbered 1..n, so unranked work gets a place at the end. + [['task_first', 1], ['task_second', 3], ['task_third', 2], ['task_call', 4]]); + const calls = readFileSync(stubFile('calls.log'), 'utf-8'); + expect(calls).toContain(`task edit ${PROJECT} task_third --rank 2`); + expect(calls).not.toContain('task_first --rank'); +}); + +test('the timeline puts work on its day, late work earlier, and a drag moves the date', async ({ page, deskUrl }) => { + await open(page, deskUrl, 'Timeline'); + const timeline = page.getByLabel('Timeline', { exact: true }); + const todayColumn = timeline.getByRole('region', { name: new RegExp(`${localDay()}$`) }); + await expect(todayColumn.getByRole('button', { name: 'Due: First in line' })).toBeVisible(); + await expect(todayColumn.getByRole('button', { name: 'Reminder: Call about phase 2' })).toContainText('14:00'); + await expect(timeline.getByRole('region', { name: 'Earlier, not done' })).toContainText('Third in line'); + + const target = otherDayThisWeek(); + await todayColumn.getByRole('button', { name: 'Due: First in line' }).dragTo(timeline.getByRole('region', { name: new RegExp(`${target}$`) })); + await expect.poll(() => stored()[0].due).toBe(target); +}); + +test('a client dossier shows the profile, work, and log, and the restricted records for the desk only', async ({ page, deskUrl }) => { + await open(page, deskUrl, 'Clients'); + await page.getByRole('button', { name: 'Client framework' }).click(); + const dossier = page.getByLabel('Client framework', { exact: true }); + await expect(dossier.getByRole('heading', { level: 1, name: 'framework' })).toBeVisible(); + await expect(page.getByRole('navigation', { name: 'Floor position' })).toContainText('Clients'); + await expect(dossier).toContainText('Framework Holdings'); + await expect(dossier.getByRole('region', { name: 'Working with them' })).toContainText('Reports on Friday.'); + await expect(dossier.getByRole('region', { name: 'Their own work' })).toContainText('Call about phase 2'); + const contacts = dossier.getByRole('region', { name: 'Contacts' }); + await expect(contacts).toContainText('desk only'); + await expect(contacts).toContainText('Head of IT'); + + await dossier.getByRole('textbox', { name: 'New interaction' }).fill('Call: agreed the phase 2 scope'); + await dossier.getByRole('button', { name: 'Log' }).click(); + await expect.poll(() => readFileSync(stubFile('calls.log'), 'utf-8')).toContain('client log framework Call: agreed the phase 2 scope'); + + await dossier.getByRole('region', { name: 'Projects' }).getByRole('button', { name: PROJECT }).click(); + await expect(page.getByLabel(`Project room ${PROJECT}`).getByRole('heading', { level: 1, name: PROJECT })).toBeVisible(); +}); diff --git a/test/ui/workspace.ts b/test/ui/workspace.ts index 1a4973a..f781f31 100644 --- a/test/ui/workspace.ts +++ b/test/ui/workspace.ts @@ -109,9 +109,11 @@ else: t["blocked"] = sub == "block"; t["blocked_reason"] = rest[0] if sub == "block" else ""; touch(t, sub) elif sub == "edit": pairs = dict(zip(rest[0::2], rest[1::2])) - for flag, field in (("--due", "due"), ("--priority", "priority"), ("--assignee", "assignee")): + for flag, field in (("--due", "due"), ("--priority", "priority"), ("--assignee", "assignee"), ("--remind-at", "remind_at")): if flag in pairs: t[field] = "" if pairs[flag] == "none" else pairs[flag] + if "--rank" in pairs: + t["rank"] = int(pairs["--rank"]) for i, value in enumerate(rest): if value == "--check-add": t["checklist"].append({"n": len(t["checklist"]) + 1, "done": False, "text": rest[i + 1]})