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/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<string, unkno } return write(run, args); } + +/** One line in the client's interaction log - a meeting, a call, an agreement - via `ws client log`. */ +export async function pmLogInteraction(run: WsInputRunner, key: unknown, request: Record<string, unknown>): 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<string, unknown>; + 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) => { 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]})