2026-03-29 22:35:35 +02:00
|
|
|
---
|
|
|
|
|
title: Workstream
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
```js
|
|
|
|
|
import {API} from "../components/config.js";
|
|
|
|
|
import {fieldRow} from "../components/field-help.js";
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
```js
|
|
|
|
|
const wsId = observable.params.id;
|
2026-05-02 12:01:45 +02:00
|
|
|
const [raw, taskRows, workplanIndex] = await Promise.all([
|
|
|
|
|
fetch(`${API}/workstreams/${wsId}`)
|
|
|
|
|
.then(r => r.ok ? r.json() : r.json().then(e => ({error: e.detail ?? `HTTP ${r.status}`})))
|
|
|
|
|
.catch(e => ({error: String(e)})),
|
|
|
|
|
fetch(`${API}/tasks/?workstream_id=${wsId}&limit=1000`)
|
|
|
|
|
.then(r => r.ok ? r.json() : [])
|
|
|
|
|
.catch(() => []),
|
|
|
|
|
fetch(`${API}/workstreams/workplan-index`)
|
|
|
|
|
.then(r => r.ok ? r.json() : {workstreams: {}})
|
|
|
|
|
.catch(() => ({workstreams: {}})),
|
|
|
|
|
]);
|
2026-03-29 22:35:35 +02:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
```js
|
|
|
|
|
if (raw.error) {
|
|
|
|
|
display(html`<div style="color:red;padding:1rem">⚠️ ${raw.error}</div>`);
|
|
|
|
|
} else {
|
2026-05-02 12:01:45 +02:00
|
|
|
const workplan = (workplanIndex.workstreams ?? {})[wsId] ?? {};
|
2026-03-29 22:35:35 +02:00
|
|
|
const name = raw.title || raw.slug || wsId;
|
|
|
|
|
const shortName = name.length > 60 ? name.slice(0, 60) + "…" : name;
|
|
|
|
|
display(html`<h1 style="font-size:1.1rem;margin-bottom:0.25rem">Workstream · <em>${shortName}</em></h1>`);
|
2026-05-02 12:01:45 +02:00
|
|
|
display(html`<p style="margin-top:0"><a href="/">← Overview</a> | <a href="/workstreams">← Workstreams</a> | <a href="/token-cost">← Token Cost</a></p>`);
|
|
|
|
|
|
|
|
|
|
display(html`<div class="ws-summary">
|
|
|
|
|
<div><span>Status</span><strong>${raw.status ?? "—"}</strong></div>
|
|
|
|
|
<div><span>Workplan</span><strong>${workplan.filename ?? "not file-backed"}</strong></div>
|
|
|
|
|
<div><span>Tasks</span><strong>${taskRows.length}</strong></div>
|
|
|
|
|
</div>`);
|
|
|
|
|
|
|
|
|
|
const statusOrder = {blocked: 0, in_progress: 1, todo: 2, done: 3, cancelled: 4};
|
|
|
|
|
const sortedTasks = [...taskRows].sort((a, b) => {
|
|
|
|
|
const statusCompare = (statusOrder[a.status] ?? 9) - (statusOrder[b.status] ?? 9);
|
|
|
|
|
if (statusCompare !== 0) return statusCompare;
|
|
|
|
|
return (a.title ?? "").localeCompare(b.title ?? "");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
display(html`<h2>Tasks</h2>`);
|
|
|
|
|
if (sortedTasks.length === 0) {
|
|
|
|
|
display(html`<p style="color:gray">No tasks are attached to this workstream.</p>`);
|
|
|
|
|
} else {
|
|
|
|
|
display(html`<table class="task-table">
|
|
|
|
|
<thead><tr><th>Status</th><th>Priority</th><th>Task</th><th>Human</th></tr></thead>
|
|
|
|
|
<tbody>${sortedTasks.map(t => html`<tr>
|
|
|
|
|
<td><span class="task-status task-status-${t.status}">${t.status}</span></td>
|
|
|
|
|
<td>${t.priority ?? "—"}</td>
|
|
|
|
|
<td><a href="/tasks/${t.id}">${t.title ?? t.id}</a></td>
|
|
|
|
|
<td>${t.needs_human ? "yes" : ""}</td>
|
|
|
|
|
</tr>`)}</tbody>
|
|
|
|
|
</table>`);
|
|
|
|
|
}
|
2026-03-29 22:35:35 +02:00
|
|
|
|
|
|
|
|
const FIELD_ORDER = [
|
|
|
|
|
"id","slug","title","status","topic_id","repo_id","repo_goal_id",
|
|
|
|
|
"created_at","updated_at",
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const rows = FIELD_ORDER.map(k => fieldRow(k, raw[k] ?? null));
|
|
|
|
|
for (const k of Object.keys(raw)) {
|
|
|
|
|
if (!FIELD_ORDER.includes(k)) rows.push(fieldRow(k, raw[k]));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
display(html`<table style="border-collapse:collapse;width:100%;max-width:640px">
|
|
|
|
|
<colgroup><col style="width:160px"><col></colgroup>
|
|
|
|
|
<tbody>${rows}</tbody>
|
|
|
|
|
</table>`);
|
|
|
|
|
}
|
|
|
|
|
```
|
2026-05-02 12:01:45 +02:00
|
|
|
|
|
|
|
|
<style>
|
|
|
|
|
.ws-summary {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
margin: 1rem 0 1.25rem;
|
|
|
|
|
max-width: 760px;
|
|
|
|
|
}
|
|
|
|
|
.ws-summary div {
|
|
|
|
|
background: var(--theme-background-alt);
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
padding: 0.75rem 0.9rem;
|
|
|
|
|
}
|
|
|
|
|
.ws-summary span {
|
|
|
|
|
display: block;
|
|
|
|
|
color: gray;
|
|
|
|
|
font-size: 0.72rem;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
margin-bottom: 0.2rem;
|
|
|
|
|
}
|
|
|
|
|
.ws-summary strong {
|
|
|
|
|
overflow-wrap: anywhere;
|
|
|
|
|
}
|
|
|
|
|
.task-table {
|
|
|
|
|
border-collapse: collapse;
|
|
|
|
|
width: 100%;
|
|
|
|
|
max-width: 900px;
|
|
|
|
|
margin-bottom: 1.25rem;
|
|
|
|
|
}
|
|
|
|
|
.task-table th, .task-table td {
|
|
|
|
|
border-bottom: 1px solid var(--theme-foreground-faint);
|
|
|
|
|
padding: 0.42rem 0.5rem;
|
|
|
|
|
text-align: left;
|
|
|
|
|
vertical-align: top;
|
|
|
|
|
}
|
|
|
|
|
.task-status {
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
display: inline-block;
|
|
|
|
|
font-size: 0.72rem;
|
|
|
|
|
padding: 0.12rem 0.38rem;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
}
|
|
|
|
|
.task-status-done { background: #e8f5e9; color: #1b5e20; }
|
|
|
|
|
.task-status-in_progress { background: #e3f2fd; color: #0d47a1; }
|
|
|
|
|
.task-status-blocked { background: #fff3e0; color: #bf360c; }
|
|
|
|
|
.task-status-todo { background: #f1f5f9; color: #334155; }
|
|
|
|
|
.task-status-cancelled { background: #f3f4f6; color: #6b7280; }
|
|
|
|
|
</style>
|